Compare commits
1140 commits
| 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 | ||
|
|
2ecb94c97e | ||
|
|
fcae1ebee9 | ||
|
|
609a5f18bf | ||
|
|
7a7843f7f9 | ||
|
|
5aa4b574a8 | ||
|
|
a93c527f7b | ||
|
|
13128ab01e | ||
|
|
af1ec40dbe | ||
|
|
106194fa52 | ||
|
|
95469a554c | ||
|
|
dc1fe604c4 | ||
|
|
f3b73a5196 | ||
|
|
80dac7d9e9 | ||
|
|
2f21b12beb | ||
|
|
d909f2d98e | ||
|
|
6ea7233626 | ||
|
|
0eb3ede8ec | ||
|
|
04265d5285 | ||
|
|
3286a94b1a | ||
|
|
86e2fd7a89 | ||
|
|
4a57d85037 | ||
|
|
cf887cacb7 | ||
|
|
24dbd04ca6 | ||
|
|
82a015c1e1 | ||
|
|
294c558b93 | ||
|
|
ebdf3e153b | ||
|
|
f9e6017b5f | ||
|
|
a959933036 | ||
|
|
3d177b05f1 | ||
|
|
b89499fb36 | ||
|
|
ec5aca0b4c | ||
|
|
a9b25e8f10 | ||
|
|
b09237c914 | ||
|
|
0b8c3abd7f | ||
|
|
deaed1fb79 | ||
|
|
e035d81ee0 | ||
|
|
27fb1d1823 | ||
|
|
1e65808dcc | ||
|
|
6e07b3fcfd | ||
|
|
a6716293cd | ||
|
|
03af372357 | ||
|
|
baaf30ff2f | ||
|
|
c761544eb7 | ||
|
|
4c92ac0f85 | ||
|
|
c8fa389267 | ||
|
|
368c8cf00f | ||
|
|
f116d551da | ||
|
|
5f73201c9c | ||
|
|
043bc22eea | ||
|
|
9083d2ae72 | ||
|
|
8beb2ef4af | ||
|
|
addb02af73 | ||
|
|
9cc5582483 | ||
|
|
cc2d740b61 | ||
|
|
b6f97536bc | ||
|
|
e831fbc773 | ||
|
|
00ad0e101c | ||
|
|
5707aa1d31 | ||
|
|
e764e8b4e4 | ||
|
|
16b1aeba91 | ||
|
|
17d16d20c7 | ||
|
|
296d9c3ca3 | ||
|
|
d27e714ce6 | ||
|
|
5861d1db87 | ||
|
|
96d4a84f52 | ||
|
|
5a36cfee67 | ||
|
|
ba0d2cd879 | ||
|
|
4f71dba90e | ||
|
|
a9e7ae398c | ||
|
|
cff700231e | ||
|
|
dbe7bdf1c3 | ||
|
|
d117f16081 | ||
|
|
b0703b4d0e | ||
|
|
111cb9688f | ||
|
|
643defd9e4 | ||
|
|
d906b87d76 | ||
|
|
8b2634df4d | ||
|
|
c4a3d52903 | ||
|
|
ad17c82909 | ||
|
|
1c74f05222 | ||
|
|
5767094519 | ||
|
|
248fc89c9b | ||
|
|
27e39b6c10 | ||
|
|
5c53615c90 | ||
|
|
6b818030eb | ||
|
|
4e86a2f703 | ||
|
|
b824ef40fb | ||
|
|
d293cc86b0 | ||
|
|
f742aa7592 | ||
|
|
e809b2875e | ||
|
|
5b2985fd6b | ||
|
|
5489c08583 | ||
|
|
d5086fc3e6 | ||
|
|
0f691ee65b | ||
|
|
b955e2f3ab | ||
|
|
3a24116b53 | ||
|
|
eedd319575 | ||
|
|
4d1df85b5c | ||
|
|
51309ebabb | ||
|
|
830ac34b67 | ||
|
|
4c5a8410f0 | ||
|
|
9b7053ab7e | ||
|
|
189cc63de7 | ||
|
|
d21a7eee93 | ||
|
|
e1e1e63dbb | ||
|
|
8e5d061af5 | ||
|
|
66db61899c | ||
|
|
9087ad5e45 | ||
|
|
0a3382cafd | ||
|
|
f23d1a8eaf | ||
|
|
4d67f481a4 | ||
|
|
a71f1ab9a6 | ||
|
|
aec08bb48b | ||
|
|
a4838cefaa | ||
|
|
e687658874 | ||
|
|
b4cf6a9563 | ||
|
|
657b8267f1 | ||
|
|
51406deaa7 | ||
|
|
bd330f0c71 | ||
|
|
8b7b916489 | ||
|
|
b99cd96726 | ||
|
|
181741bb10 | ||
|
|
f2bc50e611 | ||
|
|
823e04b073 | ||
|
|
9990e00a4a | ||
|
|
fc90d460dc | ||
|
|
6c640909f7 | ||
|
|
c90b9e5827 | ||
|
|
6c919ccd8b | ||
|
|
7995d3ac98 | ||
|
|
b19283002f | ||
|
|
f04e05ad50 | ||
|
|
754762b24d | ||
|
|
957e212e59 | ||
|
|
6249240660 | ||
|
|
25eaf89998 | ||
|
|
a3e74cbd1e | ||
|
|
c8571a4df3 | ||
|
|
02b7acd1c5 | ||
|
|
583e984c70 | ||
|
|
aac39b771d | ||
|
|
7e07c11d3a | ||
|
|
07b17caf8f | ||
|
|
3a4ddb43b9 | ||
|
|
800ebfe77e | ||
|
|
be23208f72 | ||
|
|
02877972af | ||
|
|
9b972e5d92 | ||
|
|
0c3362bc54 | ||
|
|
24097af28c | ||
|
|
0ca928a477 | ||
|
|
fb15791c2f | ||
|
|
33916deb5c | ||
|
|
3b14c35c2d | ||
|
|
43a8041d0a | ||
|
|
c69d3cac54 | ||
|
|
1e0c7cebcf | ||
|
|
9b09572382 | ||
|
|
1f9f62b0da | ||
|
|
bd890880a3 | ||
|
|
74551e83ab | ||
|
|
a6cc38e43c | ||
|
|
99a527983b | ||
|
|
5b8518a567 | ||
|
|
05101c0e08 | ||
|
|
48275c4698 | ||
|
|
e9a4b99aa5 | ||
|
|
e926a54890 | ||
|
|
62d3c2dfb1 | ||
|
|
7503accada | ||
|
|
64b9557793 | ||
|
|
08e00c7fb1 | ||
|
|
481c647342 | ||
|
|
94e29c0a1b | ||
|
|
04ea6834fb | ||
|
|
d27d99be53 | ||
|
|
6491dddc2b | ||
|
|
da07be26f4 | ||
|
|
dc51c84c54 | ||
|
|
a920da3fbd | ||
|
|
b075f8ec51 | ||
|
|
e0352a105a | ||
|
|
261edf6c65 | ||
|
|
8221e98401 | ||
|
|
f5bc31a00f | ||
|
|
2a0624f860 | ||
|
|
2f36fcfb45 | ||
|
|
6d6b1665e5 | ||
|
|
3ca9b515cf | ||
|
|
dbfe9927bf | ||
|
|
b6f89de88d | ||
|
|
86b7ef8d5c | ||
|
|
22a22a883f | ||
|
|
9f66a05f2d | ||
|
|
c35ede67b0 | ||
|
|
d7ea08cccd | ||
|
|
124fe18850 | ||
|
|
6e05ef371c | ||
|
|
13a3a57365 | ||
|
|
821f043cfe | ||
|
|
1d9dc34549 | ||
|
|
c6b960466b | ||
|
|
317c83a4d1 | ||
|
|
e452875d59 | ||
|
|
b8b741b4c0 | ||
|
|
287270fcdf | ||
|
|
c872f7890c | ||
|
|
24faeef8f3 | ||
|
|
c42da2240c | ||
|
|
b43586c1ea | ||
|
|
399e181e39 | ||
|
|
d83c1c5596 | ||
|
|
73e6697477 | ||
|
|
9817f7c328 | ||
|
|
31a9106465 | ||
|
|
489ecfafb3 | ||
|
|
691ed202e5 | ||
|
|
44b7a435d1 | ||
|
|
e5983db417 | ||
|
|
801fd28aed | ||
|
|
ddc321893d | ||
|
|
b2a6d40252 | ||
|
|
6a76a55060 | ||
|
|
dd118b4529 | ||
|
|
3a04bef074 | ||
|
|
5efdfb6aac | ||
|
|
a036005f75 | ||
|
|
07a98029c6 | ||
|
|
5517a65048 | ||
|
|
be1ee55b96 | ||
|
|
f3261ded43 | ||
|
|
23d561cc44 | ||
|
|
80e08fd74a | ||
|
|
eb3c320523 | ||
|
|
fb531c8adc | ||
|
|
c42996e492 | ||
|
|
7c2dd3ed2e | ||
|
|
46e98b84ed | ||
|
|
6730766504 | ||
|
|
ced462e718 | ||
|
|
fbd8d4b361 | ||
|
|
40fdf59a66 | ||
|
|
d0817dd83b | ||
|
|
8dc0be4a52 | ||
|
|
b53a96f574 | ||
|
|
588b6e9abd | ||
|
|
bbe0042281 | ||
|
|
5ab05b75d9 | ||
|
|
6c427185ee | ||
|
|
e6141b12e6 | ||
|
|
ec61ba50ec | ||
|
|
76eb693fb5 | ||
|
|
881df0fc02 | ||
|
|
195498879a | ||
|
|
3e79f31792 | ||
|
|
a44f0bce35 | ||
|
|
1766283cd2 | ||
|
|
b2d9b244e5 | ||
|
|
48ab83f6de | ||
|
|
68443dc337 | ||
|
|
0ea6ea6725 | ||
|
|
8ca63047d7 | ||
|
|
8b623b40ff | ||
|
|
4f42695812 | ||
|
|
07acb4f5b9 | ||
|
|
a3176fc66f | ||
|
|
3d1fa6e4ff | ||
|
|
f1a6b77d30 | ||
|
|
7be1d265ec | ||
|
|
4234f5b8f1 | ||
|
|
7a5ba6b20d | ||
|
|
b4833918cf | ||
|
|
7b01e59519 | ||
|
|
dea0ee1432 | ||
|
|
3267ae98be | ||
|
|
9652fb0221 | ||
|
|
f525abdba3 | ||
|
|
b2d71f8903 | ||
|
|
bd240f967e | ||
|
|
1d63ad5b7b | ||
|
|
6a0fd76636 | ||
|
|
d5622895d0 | ||
|
|
26d14ec6e1 | ||
|
|
5d5356e46e | ||
|
|
b6703a178e | ||
|
|
319eeb07b8 | ||
|
|
82952a204f | ||
|
|
2af90c2427 | ||
|
|
5c85001b0c | ||
|
|
cdd08e7f99 | ||
|
|
c4143e7d95 | ||
|
|
19bfd758a6 | ||
|
|
ccee1e87ce | ||
|
|
25d7a8cf07 | ||
|
|
55ee0cab91 | ||
|
|
95a2a3fb47 | ||
|
|
3202660449 | ||
|
|
4e2e6f66b6 | ||
|
|
43af2d866d | ||
|
|
b74e622923 | ||
|
|
2b55caadd1 | ||
|
|
7537d135f2 | ||
|
|
8a75295d99 | ||
|
|
afa73a2319 | ||
|
|
e500286b8b | ||
|
|
32432b18b6 | ||
|
|
399237d2fe | ||
|
|
0675ce8a03 | ||
|
|
9ad11ec5aa | ||
|
|
68584ba426 | ||
|
|
bf9667f545 | ||
|
|
244373e2af | ||
|
|
53870cd31b | ||
|
|
28ff2b416a | ||
|
|
574e129f34 | ||
|
|
538533bf3f | ||
|
|
673629dd26 | ||
|
|
4a3beafc77 | ||
|
|
17939f6b2a | ||
|
|
b10caaef54 | ||
|
|
77f0bbe614 | ||
|
|
ed519eeccc | ||
|
|
6b9f6862af | ||
|
|
ec8e598d3b | ||
|
|
2af8d8b4bd | ||
|
|
c7f8b3ca6d | ||
|
|
6956ca5915 | ||
|
|
480b1b28e5 | ||
|
|
c5a3f72b99 | ||
|
|
21970349cc | ||
|
|
c9b1ab9068 | ||
|
|
4ad2fe1a58 | ||
|
|
61cb520ab8 | ||
|
|
c1fc548e6a | ||
|
|
3f1f2f5c2d | ||
|
|
68e4da5e7e | ||
|
|
c3f67d4dc5 | ||
|
|
d59230be6d | ||
|
|
771009d4ff | ||
|
|
3d92bdab6f | ||
|
|
5562c18415 | ||
|
|
c995eb0d04 | ||
|
|
c66a9b19b5 | ||
|
|
634ff0d823 | ||
|
|
2195431c66 | ||
|
|
e553744c8e | ||
|
|
88bce610ca | ||
|
|
fff8212b0a | ||
|
|
364675b252 | ||
|
|
08ca71b7b0 | ||
|
|
d3de34ed5e | ||
|
|
4cb309f01f | ||
|
|
b0dd13b9a3 | ||
|
|
cd42c77304 | ||
|
|
bf1cbad1da | ||
|
|
b7f33622e7 | ||
|
|
b9bcdf4a51 | ||
|
|
f4db2fcd80 | ||
|
|
953b92108a | ||
|
|
4421d6cf36 | ||
|
|
7a90e7eef1 | ||
|
|
b1c48f13fd | ||
|
|
7d9ed7d114 | ||
|
|
5204b59114 | ||
|
|
9ae6b20fd1 | ||
|
|
0ad7a8955a | ||
|
|
d6801354ce | ||
|
|
bf3c30e8af | ||
|
|
b4b830fbf4 | ||
|
|
7fef5a27da | ||
|
|
d5017f8d38 | ||
|
|
0016c77836 | ||
|
|
df7ec27d1c | ||
|
|
917e6c0737 | ||
|
|
23dac3b4b7 | ||
|
|
d926e19819 | ||
|
|
ce5b9e35c7 | ||
|
|
52697ef891 | ||
|
|
a99b0ff615 | ||
|
|
08f3137c5b | ||
|
|
be97e110a6 | ||
|
|
48b0b11e88 | ||
|
|
f4518eb70f | ||
|
|
94e2c3c3e4 | ||
|
|
243fb73f94 | ||
|
|
b19b6665bb | ||
|
|
2fe0f3e7d8 | ||
|
|
d51da1b4bf | ||
|
|
4eacb65aff | ||
|
|
73ef51c8be | ||
|
|
b84e3de3e0 | ||
|
|
23c538e7aa | ||
|
|
f41905104f | ||
|
|
23d474278a | ||
|
|
a4e8c250a3 | ||
|
|
b3ef43b37e | ||
|
|
83ec0c13da | ||
|
|
a1abcd7aac | ||
|
|
ad672ed919 | ||
|
|
881fb58648 | ||
|
|
9a67a0d539 | ||
|
|
fcffb56021 | ||
|
|
c1655a9eca | ||
|
|
59e42acad9 | ||
|
|
2c2bd79ae2 | ||
|
|
c838813434 | ||
|
|
a341281548 | ||
|
|
22db781c0b | ||
|
|
69de9d6f2f | ||
|
|
59ee40b228 | ||
|
|
4fa43d59ec | ||
|
|
7e5cd2acd7 | ||
|
|
56a4102023 |
678 changed files with 33537 additions and 17818 deletions
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
github: [OxygenCobalt]
|
||||||
|
custom: ["https://paypal.me/oxycblt"]
|
||||||
9
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
|
@ -34,6 +34,8 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: What android version do you use?
|
label: What android version do you use?
|
||||||
options:
|
options:
|
||||||
|
- Android 15
|
||||||
|
- Android 14
|
||||||
- Android 13
|
- Android 13
|
||||||
- Android 12L
|
- Android 12L
|
||||||
- Android 12
|
- Android 12
|
||||||
|
|
@ -56,6 +58,13 @@ body:
|
||||||
placeholder: OnePlus 7T (LineageOS)
|
placeholder: OnePlus 7T (LineageOS)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: sample-file
|
||||||
|
attributes:
|
||||||
|
label: Provide a sample file
|
||||||
|
description: Upload a sample file the error is related to the loading or playback of music files. **IF YOU DO NOT DO THIS, I WILL BE UNABLE TO SOLVE YOUR ISSUE.** Music loading errors may indicate what file is causing the issue. Upload that file. If the audio is copyrighted, you should cut it out in an audio error while still making sure the edited file reproduces the issue. *Upload a ZIP file containing the files or share a link to a file hosted on the cloud.*
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
20
.github/workflows/android.yml
vendored
20
.github/workflows/android.yml
vendored
|
|
@ -2,33 +2,37 @@ name: Android CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "dev" ]
|
branches: []
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "dev" ]
|
branches: []
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install ninja-build
|
||||||
|
run: sudo apt-get install -y ninja-build
|
||||||
- name: Clone repository
|
- name: Clone repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Clone submodules
|
- name: Clone submodules
|
||||||
run: git submodule update --init --recursive
|
run: git submodule update --init --recursive --remote
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
cache: gradle
|
cache: gradle
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
# - name: Test app with Gradle
|
- name: Check formatting with spotless
|
||||||
# run: ./gradlew app:testDebug
|
run: ./gradlew spotlessCheck
|
||||||
|
- name: Test musikr with Gradle
|
||||||
|
run: ./gradlew musikr:testDebug
|
||||||
- name: Build debug APK with Gradle
|
- name: Build debug APK with Gradle
|
||||||
run: ./gradlew app:packageDebug
|
run: ./gradlew app:packageDebug
|
||||||
- name: Upload debug APK artifact
|
- name: Upload debug APK artifact
|
||||||
uses: actions/upload-artifact@v3.1.1
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Auxio_Canary
|
name: Auxio_Canary
|
||||||
path: ./app/build/outputs/apk/debug/app-debug.apk
|
path: ./app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,3 +13,6 @@ captures/
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
*.iml
|
*.iml
|
||||||
.cxx
|
.cxx
|
||||||
|
.kotlin
|
||||||
|
.aider*
|
||||||
|
.env
|
||||||
|
|
|
||||||
5
.gitmodules
vendored
5
.gitmodules
vendored
|
|
@ -1,3 +1,8 @@
|
||||||
[submodule "media"]
|
[submodule "media"]
|
||||||
path = media
|
path = media
|
||||||
url = https://github.com/OxygenCobalt/media.git
|
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
|
||||||
|
|
|
||||||
315
CHANGELOG.md
315
CHANGELOG.md
|
|
@ -1,5 +1,316 @@
|
||||||
# Changelog
|
# 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
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed music loading failure from improper sort systems
|
||||||
|
|
||||||
|
## 3.5.0
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Android Auto support
|
||||||
|
- Full media browser implementation
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Album covers are now loaded on a per-song basis
|
||||||
|
- MP4 sort tags are now correctly interpreted
|
||||||
|
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||||
|
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||||
|
- Added support for M3U paths starting with /storage/
|
||||||
|
- Queue no longer scrolls as quickly when dragging items
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed repeat mode not restoring on startup
|
||||||
|
- Fixed rewinding not occuring when skipping back at the beginning of the queue if
|
||||||
|
rewind before skipping was turned off
|
||||||
|
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
- For the time being, the media notification will not follow Album Covers or 1:1 Covers settings
|
||||||
|
- Playback will close automatically after some time left idle
|
||||||
|
|
||||||
|
#### Dev/Meta
|
||||||
|
- Use WEBP instead of PNG icons
|
||||||
|
|
||||||
|
#### dev -> release changes
|
||||||
|
- Re-added ability to open app from clicking on notification
|
||||||
|
- Removed tasker plugin
|
||||||
|
- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly
|
||||||
|
- M3U paths are now interpreted both as relative and absolute regardless of the format
|
||||||
|
- Added support for M3U paths starting with /storage/
|
||||||
|
- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used
|
||||||
|
- Made album cover keying more efficient at the cost of resillience
|
||||||
|
- Fixed android auto queue not respecting shuffle
|
||||||
|
|
||||||
|
## 3.4.3
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Added back option disable ReplayGain for poorly tagged libraries
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed crash when using play next on the end of a queue or with a single-song queue
|
||||||
|
- Fixed weird behavior if using play next on the end of a queue with repeat all enabled
|
||||||
|
- Fixed artist choice dialog not showing up on home screen if playing from artist/genre was enabled
|
||||||
|
|
||||||
|
## 3.4.2
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed "Add to queue" incorrectly changing the queue and crashing the app
|
||||||
|
- Fixed 1x4 and 1x3 widgets having square edges
|
||||||
|
- Fixed crash when music library updates in such a way to change music information
|
||||||
|
- Fixed crash when music library updates while scrolled in a list
|
||||||
|
- Fixed inconsistent corner radius in wafer widgets
|
||||||
|
|
||||||
|
## 3.4.1
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- R128 adjustments are now adjusted to -18 LUFS to be consistent with MP3
|
||||||
|
- Fixed double application of opus base gain
|
||||||
|
- Fixed playback state not restoring
|
||||||
|
|
||||||
|
## 3.4.0
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Gapless playback is now used whenever possible
|
||||||
|
- Added "Remember pause" setting that makes remain paused when skipping
|
||||||
|
or editing queue
|
||||||
|
- Added 1x4 and 1x3 widget forms
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Increased music timeout to 60 seconds to accomodate large cover arts
|
||||||
|
on slow storage drives
|
||||||
|
- Fixed app repeatedly crashing when automatic theme was on
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- The playback state is now saved more often, improving persistence
|
||||||
|
- The queue is now fully circular when repeat all is enabled
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
- You can no longer save, restore, or clear the playback state
|
||||||
|
- The playback session now ends if you swipe away the app while it's paused
|
||||||
|
|
||||||
|
## 3.3.3
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed music folders not behaving correctly below Android 11
|
||||||
|
|
||||||
|
## 3.3.2
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed music loading failing with an SQL error with certain music folder configurations
|
||||||
|
|
||||||
|
## 3.3.1
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- The OPUS base volume adjustment field is now parsed and used as a ReplayGain adjustment
|
||||||
|
- Added ReplayGain adjustment values to Song Properties dialog
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
- Added donation links to the about page
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed a crash occuring if you navigated to the settings page from the playlist view
|
||||||
|
and then back
|
||||||
|
- Fixed music loading failing with an SQL error with certain music folder configurations
|
||||||
|
- Fixed issue where song title on playback screen would not scroll
|
||||||
|
|
||||||
|
## 3.3.0
|
||||||
|
|
||||||
|
#### What's New
|
||||||
|
- Added ability to rewind/skip tracks by swiping back/forward
|
||||||
|
- Added support for demo release type
|
||||||
|
- Added playlist importing/export from M3U files
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Music loading will now fail when it hangs
|
||||||
|
|
||||||
|
#### What's Changed
|
||||||
|
- Albums linked to an artist only as a collaborator are no longer included
|
||||||
|
in an artist's album count
|
||||||
|
- File name and parent path have been combined into "Path" in the Song Properties
|
||||||
|
view
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed music loading failing on all huawei devices
|
||||||
|
- Fixed prior music loads not cancelling when reloading music in settings
|
||||||
|
- Fixed certain FLAC files failing to play on some devices
|
||||||
|
- Fixed music loading failing when duplicate tags with different casing was present
|
||||||
|
|
||||||
|
#### Dev/Meta
|
||||||
|
- Revamped path management
|
||||||
|
|
||||||
|
|
||||||
|
## 3.2.1
|
||||||
|
|
||||||
|
#### What's Improved
|
||||||
|
- Added support for native M4A multi-value tags based on duplicate atoms
|
||||||
|
|
||||||
|
#### What's Fixed
|
||||||
|
- Fixed app restart being required when changing intelligent sorting
|
||||||
|
or music separator settings
|
||||||
|
- Fixed widget/notification actions not working on Android 14
|
||||||
|
- Fixed app crash when using hebrew language
|
||||||
|
- Fixed app crash when adding to a playlist while in the playlist detail view
|
||||||
|
- Fixed music loading failing in some cases on Android 14
|
||||||
|
|
||||||
## 3.2.0
|
## 3.2.0
|
||||||
|
|
||||||
#### What's New
|
#### What's New
|
||||||
|
|
@ -16,6 +327,10 @@ aspect ratio setting
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
- Playlist detail view now respects playback settings
|
- Playlist detail view now respects playback settings
|
||||||
|
|
||||||
|
|
||||||
|
#### Dev/Meta
|
||||||
|
- Revamped navigation backend
|
||||||
|
|
||||||
## 3.1.4
|
## 3.1.4
|
||||||
|
|
||||||
#### What's Fixed
|
#### What's Fixed
|
||||||
|
|
|
||||||
67
README.md
67
README.md
|
|
@ -2,8 +2,8 @@
|
||||||
<h1 align="center"><b>Auxio</b></h1>
|
<h1 align="center"><b>Auxio</b></h1>
|
||||||
<h4 align="center">A simple, rational music player for android.</h4>
|
<h4 align="center">A simple, rational music player for android.</h4>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.2.0">
|
<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=v3.2.0&color=64B5F6&style=flat">
|
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
<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">
|
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||||
|
|
@ -13,33 +13,35 @@
|
||||||
</a>
|
</a>
|
||||||
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-24%2B-1450A8?style=flat">
|
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-24%2B-1450A8?style=flat">
|
||||||
</p>
|
</p>
|
||||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
|
<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">
|
<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://hosted.weblate.org/engage/auxio/"><img src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></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>
|
</p>
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
||||||
|
|
||||||
I primarily built Auxio for myself, but you can use it too, I guess.
|
|
||||||
|
|
||||||
**The default branch is the development version of the repository. For a stable version, see the master branch.**
|
**The default branch is the development version of the repository. For a stable version, see the master branch.**
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<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/shot0.png" width=250>
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
|
<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=200>
|
<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=200>
|
<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=200>
|
<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=200>
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Playback based on [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer)
|
- Playback based on [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer)
|
||||||
|
|
@ -52,6 +54,8 @@ precise/original dates, sort tags, and more
|
||||||
- SD Card-aware folder management
|
- SD Card-aware folder management
|
||||||
- Reliable playlisting functionality
|
- Reliable playlisting functionality
|
||||||
- Playback state persistence
|
- Playback state persistence
|
||||||
|
- Android auto support
|
||||||
|
- Automatic gapless playback
|
||||||
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
|
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
|
||||||
- External equalizer support (ex. Wavelet)
|
- External equalizer support (ex. Wavelet)
|
||||||
- Edge-to-edge
|
- Edge-to-edge
|
||||||
|
|
@ -60,16 +64,39 @@ precise/original dates, sort tags, and more
|
||||||
- Headset autoplay
|
- Headset autoplay
|
||||||
- Stylish widgets that automatically adapt to their size
|
- Stylish widgets that automatically adapt to their size
|
||||||
- Completely private and offline
|
- Completely private and offline
|
||||||
- No rounded album covers (Unless you want them. Then you can.)
|
- No rounded album covers (if you want them)
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files
|
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
|
||||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
|
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
|
||||||
|
- 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/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
|
## 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.
|
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
|
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
|
||||||
download the external code.
|
download the external code.
|
||||||
|
|
@ -84,6 +111,8 @@ However, feature additions and major UI changes are less likely to be accepted.
|
||||||
[Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F)
|
[Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F)
|
||||||
for more information.
|
for more information.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ plugins {
|
||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
id "androidx.navigation.safeargs.kotlin"
|
id "androidx.navigation.safeargs.kotlin"
|
||||||
id "com.diffplug.spotless"
|
|
||||||
id "kotlin-parcelize"
|
id "kotlin-parcelize"
|
||||||
id "dagger.hilt.android.plugin"
|
id "dagger.hilt.android.plugin"
|
||||||
id "kotlin-kapt"
|
id "kotlin-kapt"
|
||||||
|
|
@ -11,26 +10,25 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk 34
|
compileSdk 35
|
||||||
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
|
// Auxio implicitly depends on the native modules, explicitly specify it
|
||||||
// it here so that binary stripping will work.
|
// here so the libraries are still stripped.
|
||||||
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
ndkVersion ndk_version
|
||||||
// NDK use is unified
|
|
||||||
ndkVersion = "23.2.8568313"
|
|
||||||
namespace "org.oxycblt.auxio"
|
namespace "org.oxycblt.auxio"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.2.0"
|
versionName "4.0.4"
|
||||||
versionCode 35
|
versionCode 63
|
||||||
|
|
||||||
minSdk 24
|
minSdk min_sdk
|
||||||
targetSdk 34
|
targetSdk target_sdk
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +67,7 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,17 +77,17 @@ dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
|
||||||
def coroutines_version = '1.7.2'
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
|
|
||||||
|
|
||||||
// --- SUPPORT ---
|
// --- SUPPORT ---
|
||||||
|
|
||||||
// General
|
// General
|
||||||
implementation "androidx.core:core-ktx:1.10.1"
|
implementation "androidx.core:core-ktx:$core_version"
|
||||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||||
implementation "androidx.fragment:fragment-ktx:1.6.1"
|
// noinspection GradleDependency
|
||||||
|
implementation "androidx.fragment:fragment-ktx:1.6.2"
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
|
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
|
||||||
|
|
@ -96,11 +95,13 @@ dependencies {
|
||||||
// TODO: Report this issue and hope for a timely fix
|
// TODO: Report this issue and hope for a timely fix
|
||||||
// noinspection GradleDependency
|
// noinspection GradleDependency
|
||||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
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"
|
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
def lifecycle_version = "2.6.1"
|
def lifecycle_version = "2.8.7"
|
||||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||||
|
|
@ -111,17 +112,27 @@ dependencies {
|
||||||
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
|
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
implementation "androidx.media:media:1.6.0"
|
implementation "androidx.media:media:1.7.0"
|
||||||
|
|
||||||
|
// Android Auto
|
||||||
|
implementation "androidx.car.app:app:1.4.0"
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
def room_version = '2.6.0-alpha03'
|
|
||||||
implementation "androidx.room:room-runtime:$room_version"
|
implementation "androidx.room:room-runtime:$room_version"
|
||||||
ksp "androidx.room:room-compiler:$room_version"
|
ksp "androidx.room:room-compiler:$room_version"
|
||||||
implementation "androidx.room:room-ktx:$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 ---
|
// --- THIRD PARTY ---
|
||||||
|
|
||||||
// Exoplayer (Vendored)
|
// Exoplayer (Vendored)
|
||||||
|
|
@ -129,12 +140,12 @@ dependencies {
|
||||||
implementation project(":media-lib-decoder-ffmpeg")
|
implementation project(":media-lib-decoder-ffmpeg")
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
|
||||||
|
|
||||||
// Material
|
// Material
|
||||||
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
|
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
|
||||||
// PR a fix.
|
// PR a fix.
|
||||||
implementation "com.google.android.material:material:1.10.0-alpha06"
|
implementation "com.google.android.material:material:1.13.0-alpha07"
|
||||||
|
|
||||||
// Dependency Injection
|
// Dependency Injection
|
||||||
implementation "com.google.dagger:dagger:$hilt_version"
|
implementation "com.google.dagger:dagger:$hilt_version"
|
||||||
|
|
@ -142,21 +153,15 @@ dependencies {
|
||||||
implementation "com.google.dagger:hilt-android:$hilt_version"
|
implementation "com.google.dagger:hilt-android:$hilt_version"
|
||||||
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
||||||
|
|
||||||
// Testing
|
// Logging
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
implementation 'com.jakewharton.timber:timber:5.0.1'
|
||||||
testImplementation "junit:junit:4.13.2"
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
|
||||||
}
|
|
||||||
|
|
||||||
spotless {
|
// Speed dial
|
||||||
kotlin {
|
implementation "com.leinardi.android:speed-dial:3.3.0"
|
||||||
target "src/**/*.kt"
|
|
||||||
ktfmt().dropboxStyle()
|
|
||||||
licenseHeaderFile("NOTICE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEvaluate {
|
// Tasker integration
|
||||||
preDebugBuild.dependsOn spotlessApply
|
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
|
||||||
|
|
||||||
|
// Fuzzy search
|
||||||
|
implementation 'org.apache.commons:commons-text:1.9'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* StubTest.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
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class StubTest {
|
|
||||||
// TODO: Make tests
|
|
||||||
@Test
|
|
||||||
fun useAppContext() {
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
assertEquals("org.oxycblt.auxio.debug", appContext.packageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="info_app_name" translatable="false">Auxio Debug</string>
|
<string name="info_app_name" translatable="false">Auxio Debug</string>
|
||||||
|
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -2,13 +2,11 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->
|
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->
|
||||||
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />-->
|
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />-->
|
||||||
|
|
@ -36,11 +34,18 @@
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
tools:ignore="UnusedAttribute">
|
tools:ignore="UnusedAttribute">
|
||||||
|
|
||||||
|
<meta-data android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc"/>
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.car.app.TintableAttributionIcon"
|
||||||
|
android:resource="@drawable/ic_auxio_24" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:allowCrossUidActivitySwitchFromBelow="false"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:windowSoftInputMode="adjustPan">
|
android:windowSoftInputMode="adjustPan">
|
||||||
|
|
||||||
|
|
@ -76,33 +81,37 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!--
|
|
||||||
Service handling querying the media database, extracting metadata, and constructing
|
|
||||||
the music library.
|
|
||||||
-->
|
|
||||||
<service
|
|
||||||
android:name=".music.system.IndexerService"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:exported="false"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Service handling music playback, system components, and state saving.
|
Service handling music playback, system components, and state saving.
|
||||||
-->
|
-->
|
||||||
<service
|
<service
|
||||||
android:name=".playback.system.PlaybackService"
|
android:name=".AuxioService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
android:foregroundServiceType="mediaPlayback"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher" />
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
tools:ignore="ExportedService">
|
||||||
|
<intent-filter>
|
||||||
|
<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.
|
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||||
See the class for more info.
|
See the class for more info.
|
||||||
-->
|
-->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".playback.system.MediaButtonReceiver"
|
android:name=".playback.service.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
|
@ -132,5 +141,16 @@
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/widget_info" />
|
android:resource="@xml/widget_info" />
|
||||||
</receiver>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -1309,7 +1309,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
||||||
+ " should not be set externally.");
|
+ " should not be set externally.");
|
||||||
}
|
}
|
||||||
if (!hideable && state == STATE_HIDDEN) {
|
if (!hideable && state == STATE_HIDDEN) {
|
||||||
Log.w(TAG, "Cannot set state: " + state);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final int finalState;
|
final int finalState;
|
||||||
|
|
@ -1390,6 +1389,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
||||||
return shouldRemoveExpandedCorners;
|
return shouldRemoveExpandedCorners;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void killCorners() {
|
||||||
|
materialShapeDrawable.setCornerSize(0f);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current state of the bottom sheet.
|
* Gets the current state of the bottom sheet.
|
||||||
*
|
*
|
||||||
|
|
@ -1629,12 +1632,13 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
|
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
|
||||||
|
boolean canActuallyHide = hideable && isHideableWhenDragging();
|
||||||
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
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.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if (hideable) {
|
if (canActuallyHide) {
|
||||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||||
backEvent,
|
backEvent,
|
||||||
new AnimatorListenerAdapter() {
|
new AnimatorListenerAdapter() {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import org.oxycblt.auxio.home.HomeSettings
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
|
import org.oxycblt.auxio.util.CopyleftNoticeTree
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple, rational music player for android.
|
* A simple, rational music player for android.
|
||||||
|
|
@ -44,6 +46,14 @@ class Auxio : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
@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())
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate any settings that may have changed in an app update.
|
// Migrate any settings that may have changed in an app update.
|
||||||
imageSettings.migrate()
|
imageSettings.migrate()
|
||||||
playbackSettings.migrate()
|
playbackSettings.migrate()
|
||||||
|
|
|
||||||
219
app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Normal file
219
app/src/main/java/org/oxycblt/auxio/AuxioService.kt
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* AuxioService.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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.media.MediaBrowserServiceCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||||
|
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AuxioService :
|
||||||
|
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
|
||||||
|
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
|
||||||
|
private lateinit var playbackFragment: PlaybackServiceFragment
|
||||||
|
|
||||||
|
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
|
||||||
|
private lateinit var musicFragment: MusicServiceFragment
|
||||||
|
|
||||||
|
@SuppressLint("WrongConstant")
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
playbackFragment.handleTaskRemoved()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
musicFragment.release()
|
||||||
|
playbackFragment.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetRoot(
|
||||||
|
clientPackageName: String,
|
||||||
|
clientUid: Int,
|
||||||
|
rootHints: Bundle?
|
||||||
|
): BrowserRoot {
|
||||||
|
return musicFragment.getRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
val mediaNotification = playbackFragment.notification
|
||||||
|
if (mediaNotification != null) {
|
||||||
|
if (change == ForegroundListener.Change.MEDIA_SESSION) {
|
||||||
|
startForeground(mediaNotification.code, mediaNotification.build())
|
||||||
|
}
|
||||||
|
// Nothing changed, but don't show anything music related since we can always
|
||||||
|
// index during playback.
|
||||||
|
isForeground = true
|
||||||
|
} else {
|
||||||
|
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_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForegroundListener {
|
||||||
|
fun updateForeground(change: Change)
|
||||||
|
|
||||||
|
enum class Change {
|
||||||
|
MEDIA_SESSION,
|
||||||
|
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
|
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||||
/** DiscHeaderViewHolder */
|
/** DiscHeaderViewHolder */
|
||||||
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
||||||
|
/** DiscHeaderViewHolder */
|
||||||
|
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
|
||||||
/** EditHeaderViewHolder */
|
/** EditHeaderViewHolder */
|
||||||
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
|
const val VIEW_TYPE_EDIT_HEADER = 0xA00D
|
||||||
/** PlaylistSongViewHolder */
|
/** PlaylistSongViewHolder */
|
||||||
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
|
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
|
||||||
/** "Music playback" notification code */
|
/** "Music playback" notification code */
|
||||||
|
|
@ -59,6 +61,12 @@ object IntegerTable {
|
||||||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||||
/** MainActivity Intent request code */
|
/** MainActivity Intent request code */
|
||||||
const val REQUEST_CODE = 0xA0C0
|
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 */
|
/** RepeatMode.NONE */
|
||||||
const val REPEAT_MODE_NONE = 0xA100
|
const val REPEAT_MODE_NONE = 0xA100
|
||||||
/** RepeatMode.ALL */
|
/** RepeatMode.ALL */
|
||||||
|
|
@ -102,7 +110,7 @@ object IntegerTable {
|
||||||
/** Sort.Mode.ByDateAdded */
|
/** Sort.Mode.ByDateAdded */
|
||||||
const val SORT_BY_DATE_ADDED = 0xA118
|
const val SORT_BY_DATE_ADDED = 0xA118
|
||||||
/** ReplayGainMode.Off (No longer used but still reserved) */
|
/** ReplayGainMode.Off (No longer used but still reserved) */
|
||||||
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||||
/** ReplayGainMode.Track */
|
/** ReplayGainMode.Track */
|
||||||
const val REPLAY_GAIN_MODE_TRACK = 0xA111
|
const val REPLAY_GAIN_MODE_TRACK = 0xA111
|
||||||
/** ReplayGainMode.Album */
|
/** ReplayGainMode.Album */
|
||||||
|
|
@ -117,10 +125,10 @@ object IntegerTable {
|
||||||
const val ACTION_MODE_SHUFFLE = 0xA11B
|
const val ACTION_MODE_SHUFFLE = 0xA11B
|
||||||
/** CoverMode.Off */
|
/** CoverMode.Off */
|
||||||
const val COVER_MODE_OFF = 0xA11C
|
const val COVER_MODE_OFF = 0xA11C
|
||||||
/** CoverMode.MediaStore */
|
/** CoverMode.Balanced */
|
||||||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
const val COVER_MODE_BALANCED = 0xA11D
|
||||||
/** CoverMode.Quality */
|
/** CoverMode.Quality */
|
||||||
const val COVER_MODE_QUALITY = 0xA11E
|
const val COVER_MODE_HIGH_QUALITY = 0xA11E
|
||||||
/** PlaySong.FromAll */
|
/** PlaySong.FromAll */
|
||||||
const val PLAY_SONG_FROM_ALL = 0xA11F
|
const val PLAY_SONG_FROM_ALL = 0xA11F
|
||||||
/** PlaySong.FromAlbum */
|
/** PlaySong.FromAlbum */
|
||||||
|
|
@ -133,4 +141,8 @@ object IntegerTable {
|
||||||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||||
/** PlaySong.ByItself */
|
/** PlaySong.ByItself */
|
||||||
const val PLAY_SONG_BY_ITSELF = 0xA124
|
const val PLAY_SONG_BY_ITSELF = 0xA124
|
||||||
|
/** CoverMode.SaveSpace */
|
||||||
|
const val COVER_MODE_SAVE_SPACE = 0xA125
|
||||||
|
/** CoverMode.AsIs */
|
||||||
|
const val COVER_MODE_AS_IS = 0xA126
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,12 @@ import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
import org.oxycblt.auxio.databinding.ActivityMainBinding
|
||||||
import org.oxycblt.auxio.music.system.IndexerService
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.state.InternalPlayer
|
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||||
import org.oxycblt.auxio.playback.system.PlaybackService
|
|
||||||
import org.oxycblt.auxio.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.isNight
|
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 org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auxio's single [AppCompatActivity].
|
* Auxio's single [AppCompatActivity].
|
||||||
|
|
@ -65,22 +62,24 @@ class MainActivity : AppCompatActivity() {
|
||||||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
setupEdgeToEdge(binding.root)
|
setupEdgeToEdge(binding.root)
|
||||||
logD("Activity created")
|
L.d("Activity created")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onResume() {
|
||||||
super.onStart()
|
super.onResume()
|
||||||
|
|
||||||
startService(Intent(this, IndexerService::class.java))
|
startService(
|
||||||
startService(Intent(this, PlaybackService::class.java))
|
Intent(this, AuxioService::class.java)
|
||||||
|
.setAction(AuxioService.ACTION_START)
|
||||||
|
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||||
|
|
||||||
if (!startIntentAction(intent)) {
|
if (!startIntentAction(intent)) {
|
||||||
// No intent action to do, just restore the previously saved state.
|
// No intent action to do, just restore the previously saved state.
|
||||||
playbackModel.startAction(InternalPlayer.Action.RestoreState)
|
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
startIntentAction(intent)
|
startIntentAction(intent)
|
||||||
}
|
}
|
||||||
|
|
@ -91,10 +90,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
// Apply the color scheme. The black theme requires it's own set of themes since
|
// 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.
|
// it's not possible to modify the themes at run-time.
|
||||||
if (isNight && uiSettings.useBlackTheme) {
|
if (isNight && uiSettings.useBlackTheme) {
|
||||||
logD("Applying black theme [accent ${uiSettings.accent}]")
|
L.d("Applying black theme [accent ${uiSettings.accent}]")
|
||||||
setTheme(uiSettings.accent.blackTheme)
|
setTheme(uiSettings.accent.blackTheme)
|
||||||
} else {
|
} else {
|
||||||
logD("Applying normal theme [accent ${uiSettings.accent}]")
|
L.d("Applying normal theme [accent ${uiSettings.accent}]")
|
||||||
setTheme(uiSettings.accent.theme)
|
setTheme(uiSettings.accent.theme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -111,17 +110,17 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
|
* Transform an [Intent] given to [MainActivity] into a [DeferredPlayback] that can be used in
|
||||||
* in the playback system.
|
* the playback system.
|
||||||
*
|
*
|
||||||
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
|
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
|
||||||
* @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
|
* @return true If the analogous [DeferredPlayback] to the given [Intent] was started, false
|
||||||
* false otherwise.
|
* otherwise.
|
||||||
*/
|
*/
|
||||||
private fun startIntentAction(intent: Intent?): Boolean {
|
private fun startIntentAction(intent: Intent?): Boolean {
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
logD("No intent to handle")
|
L.d("No intent to handle")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,22 +129,22 @@ class MainActivity : AppCompatActivity() {
|
||||||
// This is because onStart can run multiple times, and thus we really don't
|
// 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
|
// want to return false and override the original delayed action with a
|
||||||
// RestoreState action.
|
// RestoreState action.
|
||||||
logD("Already used this intent")
|
L.d("Already used this intent")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
intent.putExtra(KEY_INTENT_USED, true)
|
intent.putExtra(KEY_INTENT_USED, true)
|
||||||
|
|
||||||
val action =
|
val action =
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
|
||||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
|
||||||
else -> {
|
else -> {
|
||||||
logW("Unexpected intent ${intent.action}")
|
L.w("Unexpected intent ${intent.action}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logD("Translated intent to $action")
|
L.d("Translated intent to $action")
|
||||||
playbackModel.startAction(action)
|
playbackModel.playDeferred(action)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,25 @@ import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.navigation.findNavController
|
import androidx.navigation.findNavController
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.google.android.material.R as MR
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
||||||
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import com.google.android.material.transition.MaterialFadeThrough
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
|
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||||
|
import com.leinardi.android.speeddial.SpeedDialView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import java.lang.reflect.Method
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||||
|
|
@ -42,13 +49,15 @@ import org.oxycblt.auxio.detail.Show
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.Outer
|
import org.oxycblt.auxio.home.Outer
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.MusicType
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.playback.OpenPanel
|
import org.oxycblt.auxio.playback.OpenPanel
|
||||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
||||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||||
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
|
@ -56,10 +65,12 @@ import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getDimen
|
import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
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.
|
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
|
||||||
|
|
@ -68,7 +79,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainFragment :
|
class MainFragment :
|
||||||
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
|
ViewBindingFragment<FragmentMainBinding>(),
|
||||||
|
ViewTreeObserver.OnPreDrawListener,
|
||||||
|
SpeedDialView.OnActionSelectedListener {
|
||||||
|
private val musicModel: MusicViewModel by activityViewModels()
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val listModel: ListViewModel by activityViewModels()
|
private val listModel: ListViewModel by activityViewModels()
|
||||||
|
|
@ -76,9 +90,14 @@ class MainFragment :
|
||||||
private var sheetBackCallback: SheetBackPressedCallback? = null
|
private var sheetBackCallback: SheetBackPressedCallback? = null
|
||||||
private var detailBackCallback: DetailBackPressedCallback? = null
|
private var detailBackCallback: DetailBackPressedCallback? = null
|
||||||
private var selectionBackCallback: SelectionBackPressedCallback? = null
|
private var selectionBackCallback: SelectionBackPressedCallback? = null
|
||||||
private var selectionNavigationListener: DialogAwareNavigationListener? = null
|
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
|
||||||
|
private var navigationListener: DialogAwareNavigationListener? = null
|
||||||
private var lastInsets: WindowInsets? = null
|
private var lastInsets: WindowInsets? = null
|
||||||
private var elevationNormal = 0f
|
private var elevationNormal = 0f
|
||||||
|
private var normalCornerSize = 0f
|
||||||
|
private var maxScaleXDistance = 0f
|
||||||
|
private var sheetRising: Boolean? = null
|
||||||
|
@Inject lateinit var uiSettings: UISettings
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -93,10 +112,13 @@ class MainFragment :
|
||||||
|
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
|
playbackSheetBehavior.uiSettings = uiSettings
|
||||||
|
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
queueSheetBehavior?.uiSettings = uiSettings
|
||||||
|
|
||||||
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
elevationNormal = binding.context.getDimen(MR.dimen.m3_sys_elevation_level1)
|
||||||
|
|
||||||
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
|
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
|
||||||
// that instantiating these callbacks in their respective fragments would result in the
|
// that instantiating these callbacks in their respective fragments would result in the
|
||||||
|
|
@ -109,8 +131,9 @@ class MainFragment :
|
||||||
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||||
val selectionBackCallback =
|
val selectionBackCallback =
|
||||||
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
||||||
|
speedDialBackCallback = SpeedDialBackPressedCallback()
|
||||||
|
|
||||||
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
|
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
val context = requireActivity()
|
val context = requireActivity()
|
||||||
|
|
@ -128,28 +151,51 @@ class MainFragment :
|
||||||
|
|
||||||
if (queueSheetBehavior != null) {
|
if (queueSheetBehavior != null) {
|
||||||
// In portrait mode, set up click listeners on the stacked sheets.
|
// 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 {
|
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
|
||||||
playbackModel.openQueue()
|
playbackModel.openQueue()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dual-pane mode, manually style the static queue sheet.
|
// 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 {
|
binding.queueSheet.apply {
|
||||||
// Emulate the elevated bottom sheet style.
|
// Emulate the elevated bottom sheet style.
|
||||||
background =
|
background =
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||||
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
|
shapeAppearanceModel =
|
||||||
elevation = context.getDimen(R.dimen.elevation_normal)
|
ShapeAppearanceModel.builder(
|
||||||
|
context,
|
||||||
|
MR.style.ShapeAppearance_Material3_Corner_ExtraLarge,
|
||||||
|
MR.style.ShapeAppearanceOverlay_Material3_Corner_Top)
|
||||||
|
.build()
|
||||||
|
fillColor = context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
|
||||||
}
|
}
|
||||||
// Apply bar insets for the queue's RecyclerView to use.
|
|
||||||
setOnApplyWindowInsetsListener { v, insets ->
|
|
||||||
v.updatePadding(top = insets.systemBarInsetsCompat.top)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalCornerSize = playbackSheetBehavior.sheetBackgroundDrawable.topLeftCornerResolvedSize
|
||||||
|
maxScaleXDistance =
|
||||||
|
context.getDimen(MR.dimen.m3_back_progress_bottom_container_max_scale_x_distance)
|
||||||
|
|
||||||
|
binding.playbackSheet.elevation = 0f
|
||||||
|
|
||||||
|
binding.mainScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
|
||||||
|
binding.sheetScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
|
||||||
|
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||||
|
binding.homeNewPlaylistFab.apply {
|
||||||
|
inflate(R.menu.new_playlist_actions)
|
||||||
|
setOnActionSelectedListener(this@MainFragment)
|
||||||
|
setChangeListener(::updateSpeedDial)
|
||||||
|
}
|
||||||
|
|
||||||
|
forceHideAllFabs()
|
||||||
|
updateSpeedDial(false)
|
||||||
|
updateFabVisibility(
|
||||||
|
binding,
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value,
|
||||||
|
homeModel.currentTabType.value)
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// This has to be done here instead of the playback panel to make sure that it's prioritized
|
// This has to be done here instead of the playback panel to make sure that it's prioritized
|
||||||
// by StateFlow over any detail fragment.
|
// by StateFlow over any detail fragment.
|
||||||
|
|
@ -158,6 +204,9 @@ class MainFragment :
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
|
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
|
||||||
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
|
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
|
||||||
|
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||||
|
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
||||||
|
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||||
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
|
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
|
||||||
collectImmediately(playbackModel.song, ::updateSong)
|
collectImmediately(playbackModel.song, ::updateSong)
|
||||||
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
|
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
|
||||||
|
|
@ -168,7 +217,7 @@ class MainFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
// Once we add the destination change callback, we will receive another initialization call,
|
// Once we add the destination change callback, we will receive another initialization call,
|
||||||
// so handle that by resetting the flag.
|
// so handle that by resetting the flag.
|
||||||
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
|
requireNotNull(navigationListener) { "NavigationListener was not available" }
|
||||||
.attach(binding.exploreNavHost.findNavController())
|
.attach(binding.exploreNavHost.findNavController())
|
||||||
// Listener could still reasonably fire even if we clear the binding, attach/detach
|
// Listener could still reasonably fire even if we clear the binding, attach/detach
|
||||||
// our pre-draw listener our listener in onStart/onStop respectively.
|
// our pre-draw listener our listener in onStart/onStop respectively.
|
||||||
|
|
@ -181,6 +230,7 @@ class MainFragment :
|
||||||
// navigation, navigation out of detail views, etc. We have to do this here in
|
// navigation, navigation out of detail views, etc. We have to do this here in
|
||||||
// onResume or otherwise the FragmentManager will have precedence.
|
// onResume or otherwise the FragmentManager will have precedence.
|
||||||
requireActivity().onBackPressedDispatcher.apply {
|
requireActivity().onBackPressedDispatcher.apply {
|
||||||
|
addCallback(viewLifecycleOwner, requireNotNull(speedDialBackCallback))
|
||||||
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
|
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
|
||||||
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
|
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
|
||||||
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
|
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
|
||||||
|
|
@ -190,20 +240,27 @@ class MainFragment :
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
|
requireNotNull(navigationListener) { "NavigationListener was not available" }
|
||||||
.release(binding.exploreNavHost.findNavController())
|
.release(binding.exploreNavHost.findNavController())
|
||||||
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentMainBinding) {
|
override fun onDestroyBinding(binding: FragmentMainBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
|
speedDialBackCallback = null
|
||||||
sheetBackCallback = null
|
sheetBackCallback = null
|
||||||
detailBackCallback = null
|
detailBackCallback = null
|
||||||
selectionBackCallback = null
|
selectionBackCallback = null
|
||||||
selectionNavigationListener = null
|
navigationListener = null
|
||||||
|
binding.homeNewPlaylistFab.setChangeListener(null)
|
||||||
|
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreDraw(): Boolean {
|
override fun onPreDraw(): Boolean {
|
||||||
|
// 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
|
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||||
// listener functionality. Just update all transitions before every draw. Should
|
// listener functionality. Just update all transitions before every draw. Should
|
||||||
// probably be cheap enough.
|
// probably be cheap enough.
|
||||||
|
|
@ -214,21 +271,55 @@ class MainFragment :
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
|
|
||||||
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
|
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
|
||||||
val outPlaybackRatio = 1 - playbackRatio
|
// Stupid hack to prevent you from sliding the sheet up without closing the speed
|
||||||
val halfOutRatio = min(playbackRatio * 2, 1f)
|
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the
|
||||||
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
|
// speed dial, which is super finicky behavior.
|
||||||
|
val rising = playbackRatio > 0f
|
||||||
|
if (rising != sheetRising) {
|
||||||
|
sheetRising = rising
|
||||||
|
updateFabVisibility(
|
||||||
|
binding,
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value,
|
||||||
|
homeModel.currentTabType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f)
|
||||||
|
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2
|
||||||
|
|
||||||
|
val playbackMaxXScaleDelta = maxScaleXDistance / binding.playbackSheet.width
|
||||||
|
val playbackEdgeRatio = max(playbackRatio - 0.9f, 0f) / 0.1f
|
||||||
|
val playbackBackRatio =
|
||||||
|
max(1 - ((1 - binding.playbackSheet.scaleX) / playbackMaxXScaleDelta), 0f)
|
||||||
|
val playbackLastStretchRatio = min(playbackEdgeRatio * playbackBackRatio, 1f)
|
||||||
|
binding.mainSheetScrim.alpha = playbackLastStretchRatio
|
||||||
|
|
||||||
|
playbackSheetBehavior.sheetBackgroundDrawable.setCornerSize(
|
||||||
|
normalCornerSize * (1 - playbackLastStretchRatio))
|
||||||
|
binding.exploreNavHost.isInvisible = playbackLastStretchRatio == 1f
|
||||||
|
binding.playbackSheet.translationZ = (1 - playbackLastStretchRatio) * elevationNormal
|
||||||
|
|
||||||
if (queueSheetBehavior != null) {
|
if (queueSheetBehavior != null) {
|
||||||
// Queue sheet available, the normal transition applies, but it now much be combined
|
|
||||||
// with another transition where the playback panel disappears and the playback bar
|
|
||||||
// appears as the queue sheet expands.
|
|
||||||
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
|
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
|
||||||
val halfOutQueueRatio = min(queueRatio * 2, 1f)
|
val queueInRatio = max(queueRatio - 0.5f, 0f) * 2
|
||||||
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
|
|
||||||
|
|
||||||
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
|
val queueMaxXScaleDelta = maxScaleXDistance / binding.queueSheet.width
|
||||||
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
|
val queueBackRatio =
|
||||||
binding.queueFragment.alpha = queueRatio
|
max(1 - ((1 - binding.queueSheet.scaleX) / queueMaxXScaleDelta), 0f)
|
||||||
|
|
||||||
|
val queueEdgeRatio = max(queueRatio - 0.9f, 0f) / 0.1f
|
||||||
|
|
||||||
|
val queueBarEdgeRatio = max(queueEdgeRatio - 0.5f, 0f) * 2
|
||||||
|
val queueBarBackRatio = max(queueBackRatio - 0.5f, 0f) * 2
|
||||||
|
val queueBarRatio = min(queueBarEdgeRatio * queueBarBackRatio, 1f)
|
||||||
|
|
||||||
|
val queuePanelEdgeRatio = min(queueEdgeRatio * 2, 1f)
|
||||||
|
val queuePanelBackRatio = min(queueBackRatio * 2, 1f)
|
||||||
|
val queuePanelRatio = 1 - min(queuePanelEdgeRatio * queuePanelBackRatio, 1f)
|
||||||
|
|
||||||
|
binding.playbackBarFragment.alpha = max(playbackOutRatio, queueBarRatio)
|
||||||
|
binding.playbackPanelFragment.alpha = min(playbackInRatio, queuePanelRatio)
|
||||||
|
binding.queueFragment.alpha = queueInRatio
|
||||||
|
|
||||||
if (playbackModel.song.value != null) {
|
if (playbackModel.song.value != null) {
|
||||||
// Playback sheet intercepts queue sheet touch events, prevent that from
|
// Playback sheet intercepts queue sheet touch events, prevent that from
|
||||||
|
|
@ -238,33 +329,18 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No queue sheet, fade normally based on the playback sheet
|
// No queue sheet, fade normally based on the playback sheet
|
||||||
binding.playbackBarFragment.alpha = 1 - halfOutRatio
|
binding.playbackBarFragment.alpha = playbackOutRatio
|
||||||
binding.playbackPanelFragment.alpha = halfInPlaybackRatio
|
binding.playbackPanelFragment.alpha = playbackInRatio
|
||||||
|
(binding.queueSheet.background as MaterialShapeDrawable).shapeAppearanceModel =
|
||||||
|
ShapeAppearanceModel.builder()
|
||||||
|
.setTopLeftCornerSize(normalCornerSize)
|
||||||
|
.setTopRightCornerSize(normalCornerSize * (1 - playbackLastStretchRatio))
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fade out the content as the playback panel expands.
|
|
||||||
// TODO: Replace with shadow?
|
|
||||||
binding.exploreNavHost.apply {
|
|
||||||
alpha = outPlaybackRatio
|
|
||||||
// Prevent interactions when the content fully fades out.
|
|
||||||
isInvisible = alpha == 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce playback sheet elevation as it expands. This involves both updating the
|
|
||||||
// shadow elevation for older versions, and fading out the background drawable
|
|
||||||
// containing the elevation overlay.
|
|
||||||
binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
|
|
||||||
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
|
|
||||||
|
|
||||||
// Fade out the playback bar as the panel expands.
|
// Fade out the playback bar as the panel expands.
|
||||||
binding.playbackBarFragment.apply {
|
binding.playbackBarFragment.apply {
|
||||||
// Prevent interactions when the playback bar fully fades out.
|
// Prevent interactions when the playback bar fully fades out.
|
||||||
isInvisible = alpha == 0f
|
isInvisible = alpha == 0f
|
||||||
// As the playback bar expands, we also want to subtly translate the bar to
|
|
||||||
// align with the top inset. This results in both a smooth transition from the bar
|
|
||||||
// to the playback panel's toolbar, but also a correctly positioned playback bar
|
|
||||||
// for when the queue sheet expands.
|
|
||||||
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent interactions when the playback panel fully fades out.
|
// Prevent interactions when the playback panel fully fades out.
|
||||||
|
|
@ -272,7 +348,7 @@ class MainFragment :
|
||||||
|
|
||||||
binding.queueSheet.apply {
|
binding.queueSheet.apply {
|
||||||
// Queue sheet (not queue content) should fade out with the playback panel.
|
// Queue sheet (not queue content) should fade out with the playback panel.
|
||||||
alpha = halfInPlaybackRatio
|
alpha = playbackInRatio
|
||||||
// Prevent interactions when the queue sheet fully fades out.
|
// Prevent interactions when the queue sheet fully fades out.
|
||||||
binding.queueSheet.isInvisible = alpha == 0f
|
binding.queueSheet.isInvisible = alpha == 0f
|
||||||
}
|
}
|
||||||
|
|
@ -291,9 +367,160 @@ class MainFragment :
|
||||||
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
||||||
.invalidateEnabled()
|
.invalidateEnabled()
|
||||||
|
|
||||||
|
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
|
||||||
|
binding.mainFabContainer.isVisible =
|
||||||
|
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
|
||||||
|
when (actionItem.id) {
|
||||||
|
R.id.action_new_playlist -> {
|
||||||
|
L.d("Creating playlist")
|
||||||
|
musicModel.createPlaylist()
|
||||||
|
}
|
||||||
|
R.id.action_import_playlist -> {
|
||||||
|
L.d("Importing playlist")
|
||||||
|
musicModel.importPlaylist()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
// Returning false to close the speed dial results in no animation, manually close instead.
|
||||||
|
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||||
|
requireBinding().homeNewPlaylistFab.close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onExploreNavigate() {
|
||||||
|
listModel.dropSelection()
|
||||||
|
updateFabVisibility(
|
||||||
|
requireBinding(),
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value,
|
||||||
|
homeModel.currentTabType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCurrentTab(tabType: MusicType) {
|
||||||
|
val binding = requireBinding()
|
||||||
|
updateFabVisibility(
|
||||||
|
binding, homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateIndexerState(state: IndexingState?) {
|
||||||
|
if (state is IndexingState.Completed && state.error == null) {
|
||||||
|
L.d("Received ok response")
|
||||||
|
val binding = requireBinding()
|
||||||
|
updateFabVisibility(
|
||||||
|
binding,
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value,
|
||||||
|
homeModel.currentTabType.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||||
|
val binding = requireBinding()
|
||||||
|
updateFabVisibility(binding, songs, isFastScrolling, homeModel.currentTabType.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFabVisibility(
|
||||||
|
binding: FragmentMainBinding,
|
||||||
|
songs: List<Song>,
|
||||||
|
isFastScrolling: Boolean,
|
||||||
|
tabType: MusicType
|
||||||
|
) {
|
||||||
|
// If there are no songs, it's likely that the library has not been loaded, so
|
||||||
|
// 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)) {
|
||||||
|
L.d("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||||
|
forceHideAllFabs()
|
||||||
|
} else {
|
||||||
|
if (tabType != MusicType.PLAYLISTS) {
|
||||||
|
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||||
|
L.d("Animating transition")
|
||||||
|
binding.homeNewPlaylistFab.hide(
|
||||||
|
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||||
|
override fun onHidden(fab: FloatingActionButton) {
|
||||||
|
super.onHidden(fab)
|
||||||
|
if (shouldHideAllFabs(
|
||||||
|
binding,
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.homeShuffleFab.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
L.d("Showing immediately")
|
||||||
|
binding.homeShuffleFab.show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
L.d("Showing playlist button")
|
||||||
|
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||||
|
L.d("Animating transition")
|
||||||
|
binding.homeShuffleFab.hide(
|
||||||
|
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||||
|
override fun onHidden(fab: FloatingActionButton) {
|
||||||
|
super.onHidden(fab)
|
||||||
|
if (shouldHideAllFabs(
|
||||||
|
binding,
|
||||||
|
homeModel.songList.value,
|
||||||
|
homeModel.isFastScrolling.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.homeNewPlaylistFab.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
L.d("Showing immediately")
|
||||||
|
binding.homeNewPlaylistFab.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldHideAllFabs(
|
||||||
|
binding: FragmentMainBinding,
|
||||||
|
songs: List<Song>,
|
||||||
|
isFastScrolling: Boolean
|
||||||
|
) =
|
||||||
|
binding.exploreNavHost.findNavController().currentDestination?.id != R.id.home_fragment ||
|
||||||
|
sheetRising == true ||
|
||||||
|
songs.isEmpty() ||
|
||||||
|
isFastScrolling
|
||||||
|
|
||||||
|
private fun forceHideAllFabs() {
|
||||||
|
val binding = requireBinding()
|
||||||
|
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||||
|
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
|
||||||
|
}
|
||||||
|
if (binding.homeNewPlaylistFab.isOpen) {
|
||||||
|
binding.homeNewPlaylistFab.close()
|
||||||
|
}
|
||||||
|
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||||
|
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSpeedDial(open: Boolean) {
|
||||||
|
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||||
|
.invalidateEnabled(open)
|
||||||
|
val binding = requireBinding()
|
||||||
|
binding.mainScrim.isInvisible = !open
|
||||||
|
binding.sheetScrim.isInvisible = !open
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleShow(show: Show?) {
|
private fun handleShow(show: Show?) {
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongAlbumDetails,
|
is Show.SongAlbumDetails,
|
||||||
|
|
@ -329,7 +556,7 @@ class MainFragment :
|
||||||
|
|
||||||
private fun handlePanel(panel: OpenPanel?) {
|
private fun handlePanel(panel: OpenPanel?) {
|
||||||
if (panel == null) return
|
if (panel == null) return
|
||||||
logD("Trying to update panel to $panel")
|
L.d("Trying to update panel to $panel")
|
||||||
when (panel) {
|
when (panel) {
|
||||||
OpenPanel.MAIN -> tryClosePlaybackPanel()
|
OpenPanel.MAIN -> tryClosePlaybackPanel()
|
||||||
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
|
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
|
||||||
|
|
@ -345,7 +572,7 @@ class MainFragment :
|
||||||
|
|
||||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
// 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
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -356,7 +583,7 @@ class MainFragment :
|
||||||
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||||
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
||||||
// playback panel can shown.
|
// playback panel can shown.
|
||||||
logD("Collapsing queue sheet")
|
L.d("Collapsing queue sheet")
|
||||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,7 +594,7 @@ class MainFragment :
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||||
// Playback sheet (and possibly queue) needs to be collapsed.
|
// Playback sheet (and possibly queue) needs to be collapsed.
|
||||||
logD("Collapsing playback and queue sheets")
|
L.d("Collapsing playback and queue sheets")
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||||
|
|
@ -393,7 +620,7 @@ class MainFragment :
|
||||||
val playbackSheetBehavior =
|
val playbackSheetBehavior =
|
||||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||||
logD("Unhiding and enabling playback sheet")
|
L.d("Unhiding and enabling playback sheet")
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||||
|
|
@ -414,7 +641,7 @@ class MainFragment :
|
||||||
val queueSheetBehavior =
|
val queueSheetBehavior =
|
||||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
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.
|
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||||
queueSheetBehavior?.apply {
|
queueSheetBehavior?.apply {
|
||||||
|
|
@ -433,19 +660,49 @@ class MainFragment :
|
||||||
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
|
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
|
||||||
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
|
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
|
||||||
) : OnBackPressedCallback(false) {
|
) : OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
||||||
// If expanded, collapse the queue sheet first.
|
|
||||||
if (queueSheetShown()) {
|
if (queueSheetShown()) {
|
||||||
unlikelyToBeNull(queueSheetBehavior).state =
|
unlikelyToBeNull(queueSheetBehavior).startBackProgress(backEvent)
|
||||||
BackportBottomSheetBehavior.STATE_COLLAPSED
|
}
|
||||||
logD("Collapsed queue sheet")
|
|
||||||
|
if (playbackSheetShown()) {
|
||||||
|
playbackSheetBehavior.startBackProgress(backEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
||||||
|
if (queueSheetShown()) {
|
||||||
|
unlikelyToBeNull(queueSheetBehavior).updateBackProgress(backEvent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If expanded, collapse the playback sheet next.
|
|
||||||
if (playbackSheetShown()) {
|
if (playbackSheetShown()) {
|
||||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
playbackSheetBehavior.updateBackProgress(backEvent)
|
||||||
logD("Collapsed playback sheet")
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (queueSheetShown()) {
|
||||||
|
unlikelyToBeNull(queueSheetBehavior).handleBackInvoked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackSheetShown()) {
|
||||||
|
playbackSheetBehavior.handleBackInvoked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnBackCancelled() {
|
||||||
|
if (queueSheetShown()) {
|
||||||
|
unlikelyToBeNull(queueSheetBehavior).cancelBackProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playbackSheetShown()) {
|
||||||
|
playbackSheetBehavior.cancelBackProgress()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -468,7 +725,7 @@ class MainFragment :
|
||||||
OnBackPressedCallback(false) {
|
OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
if (detailModel.dropPlaylistEdit()) {
|
if (detailModel.dropPlaylistEdit()) {
|
||||||
logD("Dropped playlist edits")
|
L.d("Dropped playlist edits")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -481,7 +738,7 @@ class MainFragment :
|
||||||
OnBackPressedCallback(false) {
|
OnBackPressedCallback(false) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
if (listModel.dropSelection()) {
|
if (listModel.dropSelection()) {
|
||||||
logD("Dropped selection")
|
L.d("Dropped selection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,4 +746,26 @@ class MainFragment :
|
||||||
isEnabled = selection.isNotEmpty()
|
isEnabled = selection.isNotEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
val binding = requireBinding()
|
||||||
|
if (binding.homeNewPlaylistFab.isOpen) {
|
||||||
|
binding.homeNewPlaylistFab.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateEnabled(open: Boolean) {
|
||||||
|
isEnabled = open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val FAB_HIDE_FROM_USER_FIELD: Method by
|
||||||
|
lazyReflectedMethod(
|
||||||
|
FloatingActionButton::class,
|
||||||
|
"hide",
|
||||||
|
FloatingActionButton.OnVisibilityChangedListener::class,
|
||||||
|
Boolean::class)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,43 +19,34 @@
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
|
||||||
import org.oxycblt.auxio.list.Divider
|
|
||||||
import org.oxycblt.auxio.list.Header
|
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
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.MusicViewModel
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.auxio.music.resolve
|
||||||
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.canScroll
|
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
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].
|
* A [ListFragment] that shows information about an [Album].
|
||||||
|
|
@ -63,60 +54,17 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AlbumDetailFragment :
|
class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
ListFragment<Song, FragmentDetailBinding>(),
|
|
||||||
AlbumDetailHeaderAdapter.Listener,
|
|
||||||
DetailListAdapter.Listener<Song> {
|
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
|
||||||
override val listModel: ListViewModel by activityViewModels()
|
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
|
||||||
|
|
||||||
// Information about what album to display is initially within the navigation arguments
|
// Information about what album to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an album.
|
// as a UID, as that is the only safe way to parcel an album.
|
||||||
private val args: AlbumDetailFragmentArgs by navArgs()
|
private val args: AlbumDetailFragmentArgs by navArgs()
|
||||||
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
|
|
||||||
private val albumListAdapter = AlbumDetailListAdapter(this)
|
private val albumListAdapter = AlbumDetailListAdapter(this)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun getDetailListAdapter() = albumListAdapter
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
|
||||||
// semantically correct, but are also too buggy to be sensible.
|
|
||||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
|
||||||
|
|
||||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
|
||||||
binding.detailSelectionToolbar
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP --
|
|
||||||
binding.detailNormalToolbar.apply {
|
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
|
||||||
overrideOnOverflowMenuClick {
|
|
||||||
listModel.openMenu(
|
|
||||||
R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailRecycler.apply {
|
|
||||||
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
|
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
|
||||||
if (it != 0) {
|
|
||||||
val item = detailModel.albumSongList.value[it - 1]
|
|
||||||
item is Divider || item is Header || item is Disc
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- VIEWMODEL SETUP ---
|
// -- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setAlbum(args.albumUid)
|
detailModel.setAlbum(args.albumUid)
|
||||||
|
|
@ -126,6 +74,7 @@ class AlbumDetailFragment :
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||||
|
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||||
|
|
@ -133,8 +82,6 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
|
||||||
binding.detailRecycler.adapter = null
|
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
detailModel.albumSongInstructions.consume()
|
detailModel.albumSongInstructions.consume()
|
||||||
|
|
@ -144,34 +91,68 @@ class AlbumDetailFragment :
|
||||||
playbackModel.play(item, detailModel.playInAlbumWith)
|
playbackModel.play(item, detailModel.playInAlbumWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenParentMenu() {
|
||||||
|
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Song) {
|
override fun onOpenMenu(item: Song) {
|
||||||
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
|
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlay() {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffle() {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpenSortMenu() {
|
override fun onOpenSortMenu() {
|
||||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
|
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNavigateToParentArtist() {
|
|
||||||
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAlbum(album: Album?) {
|
private fun updateAlbum(album: Album?) {
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
logD("No album to show, navigating away")
|
L.d("No album to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
|
|
||||||
albumHeaderAdapter.setParent(album)
|
val binding = requireBinding()
|
||||||
|
val context = requireContext()
|
||||||
|
val name = album.name.resolve(context)
|
||||||
|
|
||||||
|
binding.detailToolbarTitle.text = name
|
||||||
|
binding.detailCover.bind(album)
|
||||||
|
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||||
|
binding.detailType.text = album.releaseType.resolve(context)
|
||||||
|
binding.detailName.text = name
|
||||||
|
// Artist name maps to the subhead text
|
||||||
|
binding.detailSubhead.apply {
|
||||||
|
text = album.artists.resolveNames(context)
|
||||||
|
|
||||||
|
// Add a QoL behavior where navigation to the artist will occur if the artist
|
||||||
|
// name is pressed.
|
||||||
|
setOnClickListener {
|
||||||
|
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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?.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) {
|
private fun updateList(list: List<Item>) {
|
||||||
|
|
@ -182,7 +163,7 @@ class AlbumDetailFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongDetails -> {
|
is Show.SongDetails -> {
|
||||||
logD("Navigating to ${show.song}")
|
L.d("Navigating to ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -191,11 +172,11 @@ class AlbumDetailFragment :
|
||||||
// fragment should be launched otherwise.
|
// fragment should be launched otherwise.
|
||||||
is Show.SongAlbumDetails -> {
|
is Show.SongAlbumDetails -> {
|
||||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
|
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)
|
scrollToAlbumSong(show.song)
|
||||||
detailModel.toShow.consume()
|
detailModel.toShow.consume()
|
||||||
} else {
|
} else {
|
||||||
logD("Navigating to the album of ${show.song}")
|
L.d("Navigating to the album of ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -205,27 +186,27 @@ class AlbumDetailFragment :
|
||||||
// detail fragment.
|
// detail fragment.
|
||||||
is Show.AlbumDetails -> {
|
is Show.AlbumDetails -> {
|
||||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
|
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)
|
binding.detailRecycler.scrollToPosition(0)
|
||||||
detailModel.toShow.consume()
|
detailModel.toShow.consume()
|
||||||
} else {
|
} else {
|
||||||
logD("Navigating to ${show.album}")
|
L.d("Navigating to ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Show.ArtistDetails -> {
|
is Show.ArtistDetails -> {
|
||||||
logD("Navigating to ${show.artist}")
|
L.d("Navigating to ${show.artist}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
|
||||||
}
|
}
|
||||||
is Show.SongArtistDecision -> {
|
is Show.SongArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.song}")
|
L.d("Navigating to artist choices for ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumArtistDecision -> {
|
is Show.AlbumArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.album}")
|
L.d("Navigating to artist choices for ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
|
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -268,17 +249,25 @@ class AlbumDetailFragment :
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaylistDecision.Add -> {
|
is PlaylistDecision.Add -> {
|
||||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||||
AlbumDetailFragmentDirections.addToPlaylist(
|
AlbumDetailFragmentDirections.addToPlaylist(
|
||||||
decision.songs.map { it.uid }.toTypedArray())
|
decision.songs.map { it.uid }.toTypedArray())
|
||||||
}
|
}
|
||||||
is PlaylistDecision.New,
|
is PlaylistDecision.New,
|
||||||
|
is PlaylistDecision.Import,
|
||||||
is PlaylistDecision.Rename,
|
is PlaylistDecision.Rename,
|
||||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
is PlaylistDecision.Delete,
|
||||||
|
is PlaylistDecision.Export -> error("Unexpected playlist decision $decision")
|
||||||
}
|
}
|
||||||
findNavController().navigateSafe(directions)
|
findNavController().navigateSafe(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||||
|
if (message == null) return
|
||||||
|
requireContext().showToast(message.stringRes)
|
||||||
|
musicModel.playlistMessage.consume()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
albumListAdapter.setPlaying(
|
albumListAdapter.setPlaying(
|
||||||
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
||||||
|
|
@ -289,11 +278,11 @@ class AlbumDetailFragment :
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaybackDecision.PlayFromArtist -> {
|
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)
|
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||||
}
|
}
|
||||||
is PlaybackDecision.PlayFromGenre -> {
|
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)
|
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,6 +296,14 @@ class AlbumDetailFragment :
|
||||||
if (pos != -1) {
|
if (pos != -1) {
|
||||||
// Only scroll if the song is within this album.
|
// Only scroll if the song is within this album.
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
|
// 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 {
|
binding.detailRecycler.post {
|
||||||
// Use a custom smooth scroller that will settle the item in the middle of
|
// Use a custom smooth scroller that will settle the item in the middle of
|
||||||
// the screen rather than the end.
|
// the screen rather than the end.
|
||||||
|
|
@ -329,12 +326,9 @@ class AlbumDetailFragment :
|
||||||
|
|
||||||
// Make sure to increment the position to make up for the detail header
|
// Make sure to increment the position to make up for the detail header
|
||||||
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
|
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
|
||||||
|
|
||||||
// If the recyclerview can scroll, its certain that it will have to scroll to
|
|
||||||
// correctly center the playing item, so make sure that the Toolbar is lifted in
|
|
||||||
// that case.
|
|
||||||
binding.detailAppbar.isLifted = binding.detailRecycler.canScroll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,42 +19,33 @@
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
|
||||||
import org.oxycblt.auxio.list.Divider
|
|
||||||
import org.oxycblt.auxio.list.Header
|
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
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.MusicViewModel
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
|
import org.oxycblt.auxio.music.resolve
|
||||||
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
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].
|
* A [ListFragment] that shows information about an [Artist].
|
||||||
|
|
@ -62,63 +53,17 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ArtistDetailFragment :
|
class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
||||||
ListFragment<Music, FragmentDetailBinding>(),
|
|
||||||
DetailHeaderAdapter.Listener,
|
|
||||||
DetailListAdapter.Listener<Music> {
|
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
|
||||||
override val listModel: ListViewModel by activityViewModels()
|
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
|
||||||
// Information about what artist to display is initially within the navigation arguments
|
// Information about what artist to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an artist.
|
// as a UID, as that is the only safe way to parcel an artist.
|
||||||
private val args: ArtistDetailFragmentArgs by navArgs()
|
private val args: ArtistDetailFragmentArgs by navArgs()
|
||||||
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
|
|
||||||
private val artistListAdapter = ArtistDetailListAdapter(this)
|
private val artistListAdapter = ArtistDetailListAdapter(this)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun getDetailListAdapter() = artistListAdapter
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
|
||||||
// semantically correct, but are also too buggy to be sensible.
|
|
||||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
|
||||||
|
|
||||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
|
||||||
binding.detailSelectionToolbar
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
|
||||||
binding.detailNormalToolbar.apply {
|
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
|
||||||
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
|
||||||
overrideOnOverflowMenuClick {
|
|
||||||
listModel.openMenu(
|
|
||||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailRecycler.apply {
|
|
||||||
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
|
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
|
||||||
if (it != 0) {
|
|
||||||
val item =
|
|
||||||
detailModel.artistSongList.value.getOrElse(it - 1) {
|
|
||||||
return@setFullWidthLookup false
|
|
||||||
}
|
|
||||||
item is Divider || item is Header
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setArtist(args.artistUid)
|
detailModel.setArtist(args.artistUid)
|
||||||
|
|
@ -128,6 +73,7 @@ class ArtistDetailFragment :
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||||
|
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||||
|
|
@ -135,8 +81,6 @@ class ArtistDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
|
||||||
binding.detailRecycler.adapter = null
|
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
detailModel.artistSongInstructions.consume()
|
detailModel.artistSongInstructions.consume()
|
||||||
|
|
@ -150,6 +94,10 @@ class ArtistDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenParentMenu() {
|
||||||
|
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Music) {
|
override fun onOpenMenu(item: Music) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
|
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
|
||||||
|
|
@ -158,26 +106,75 @@ class ArtistDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlay() {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffle() {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpenSortMenu() {
|
override fun onOpenSortMenu() {
|
||||||
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
|
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateArtist(artist: Artist?) {
|
private fun updateArtist(artist: Artist?) {
|
||||||
if (artist == null) {
|
if (artist == null) {
|
||||||
logD("No artist to show, navigating away")
|
L.d("No artist to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
val binding = requireBinding()
|
||||||
artistHeaderAdapter.setParent(artist)
|
val context = requireContext()
|
||||||
|
val name = artist.name.resolve(context)
|
||||||
|
binding.detailToolbarTitle.text = name
|
||||||
|
|
||||||
|
binding.detailCover.bind(artist)
|
||||||
|
binding.detailType.text = context.getString(R.string.lbl_artist)
|
||||||
|
binding.detailName.text = name
|
||||||
|
|
||||||
|
// Song and album counts map to the info
|
||||||
|
binding.detailInfo.text =
|
||||||
|
context.getString(
|
||||||
|
R.string.fmt_two,
|
||||||
|
if (artist.explicitAlbums.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_album_count)
|
||||||
|
},
|
||||||
|
if (artist.songs.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_song_count)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (artist.songs.isNotEmpty()) {
|
||||||
|
// Information about the artist's genre(s) map to the sub-head text
|
||||||
|
binding.detailSubhead.apply {
|
||||||
|
isVisible = true
|
||||||
|
text = artist.genres.resolveNames(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the case that this header used to he configured to have no songs,
|
||||||
|
// we want to reset the visibility of all information that was hidden.
|
||||||
|
binding.detailPlayButton?.isVisible = true
|
||||||
|
binding.detailShuffleButton?.isVisible = true
|
||||||
|
} else {
|
||||||
|
// 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.
|
||||||
|
L.d("Artist is empty, disabling genres and playback")
|
||||||
|
binding.detailSubhead.isVisible = false
|
||||||
|
binding.detailPlayButton?.isEnabled = false
|
||||||
|
binding.detailShuffleButton?.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) {
|
private fun updateList(list: List<Item>) {
|
||||||
|
|
@ -188,14 +185,14 @@ class ArtistDetailFragment :
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongDetails -> {
|
is Show.SongDetails -> {
|
||||||
logD("Navigating to ${show.song}")
|
L.d("Navigating to ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Songs should be shown in their album, not in their artist.
|
// Songs should be shown in their album, not in their artist.
|
||||||
is Show.SongAlbumDetails -> {
|
is Show.SongAlbumDetails -> {
|
||||||
logD("Navigating to the album of ${show.song}")
|
L.d("Navigating to the album of ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +200,7 @@ class ArtistDetailFragment :
|
||||||
// Launch a new detail view for an album, even if it is part of
|
// Launch a new detail view for an album, even if it is part of
|
||||||
// this artist.
|
// this artist.
|
||||||
is Show.AlbumDetails -> {
|
is Show.AlbumDetails -> {
|
||||||
logD("Navigating to ${show.album}")
|
L.d("Navigating to ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -212,22 +209,22 @@ class ArtistDetailFragment :
|
||||||
// scroll back to the top. Otherwise launch a new detail view.
|
// scroll back to the top. Otherwise launch a new detail view.
|
||||||
is Show.ArtistDetails -> {
|
is Show.ArtistDetails -> {
|
||||||
if (show.artist == detailModel.currentArtist.value) {
|
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)
|
binding.detailRecycler.scrollToPosition(0)
|
||||||
detailModel.toShow.consume()
|
detailModel.toShow.consume()
|
||||||
} else {
|
} else {
|
||||||
logD("Navigating to ${show.artist}")
|
L.d("Navigating to ${show.artist}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Show.SongArtistDecision -> {
|
is Show.SongArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.song}")
|
L.d("Navigating to artist choices for ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumArtistDecision -> {
|
is Show.AlbumArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.album}")
|
L.d("Navigating to artist choices for ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -271,17 +268,25 @@ class ArtistDetailFragment :
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaylistDecision.Add -> {
|
is PlaylistDecision.Add -> {
|
||||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||||
ArtistDetailFragmentDirections.addToPlaylist(
|
ArtistDetailFragmentDirections.addToPlaylist(
|
||||||
decision.songs.map { it.uid }.toTypedArray())
|
decision.songs.map { it.uid }.toTypedArray())
|
||||||
}
|
}
|
||||||
is PlaylistDecision.New,
|
is PlaylistDecision.New,
|
||||||
|
is PlaylistDecision.Import,
|
||||||
is PlaylistDecision.Rename,
|
is PlaylistDecision.Rename,
|
||||||
|
is PlaylistDecision.Export,
|
||||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||||
}
|
}
|
||||||
findNavController().navigateSafe(directions)
|
findNavController().navigateSafe(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||||
|
if (message == null) return
|
||||||
|
requireContext().showToast(message.stringRes)
|
||||||
|
musicModel.playlistMessage.consume()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||||
val playingItem =
|
val playingItem =
|
||||||
|
|
@ -304,7 +309,7 @@ class ArtistDetailFragment :
|
||||||
is PlaybackDecision.PlayFromArtist ->
|
is PlaybackDecision.PlayFromArtist ->
|
||||||
error("Unexpected playback decision $decision")
|
error("Unexpected playback decision $decision")
|
||||||
is PlaybackDecision.PlayFromGenre -> {
|
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)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
132
app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt
Normal file
132
app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* DetailFragment.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.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
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.ListFragment
|
||||||
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
|
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>(),
|
||||||
|
DetailListAdapter.Listener<C>,
|
||||||
|
AppBarLayout.OnOffsetChangedListener {
|
||||||
|
protected val detailModel: DetailViewModel by activityViewModels()
|
||||||
|
override val listModel: ListViewModel by activityViewModels()
|
||||||
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var spacingSmall = 0
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||||
|
// semantically correct, but are also too buggy to be sensible.
|
||||||
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
|
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||||
|
|
||||||
|
abstract fun getDetailListAdapter(): DetailListAdapter
|
||||||
|
|
||||||
|
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||||
|
binding.detailSelectionToolbar
|
||||||
|
|
||||||
|
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
// --- UI SETUP ---
|
||||||
|
binding.detailAppbar.addOnOffsetChangedListener(this)
|
||||||
|
|
||||||
|
binding.detailNormalToolbar.apply {
|
||||||
|
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||||
|
setOnMenuItemClickListener(this@DetailFragment)
|
||||||
|
overrideOnOverflowMenuClick { onOpenParentMenu() }
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.detailRecycler.apply {
|
||||||
|
adapter = getDetailListAdapter()
|
||||||
|
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||||
|
if (it != 0) {
|
||||||
|
val item =
|
||||||
|
detailModel.artistSongList.value.getOrElse(it - 1) {
|
||||||
|
return@setFullWidthLookup false
|
||||||
|
}
|
||||||
|
item is PlainDivider || item is PlainHeader
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spacingSmall = requireContext().getDimenPixels(R.dimen.spacing_small)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
|
super.onDestroyBinding(binding)
|
||||||
|
binding.detailAppbar.removeOnOffsetChangedListener(this)
|
||||||
|
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||||
|
binding.detailRecycler.adapter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||||
|
val binding = requireBinding()
|
||||||
|
val range = appBarLayout.totalScrollRange
|
||||||
|
val ratio = abs(verticalOffset.toFloat()) / range.toFloat()
|
||||||
|
|
||||||
|
val outRatio = min(ratio * 2, 1f)
|
||||||
|
val detailHeader = binding.detailHeader
|
||||||
|
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
|
* Copyright (c) 2024 Auxio Project
|
||||||
* MetadataModule.kt is part of Auxio.
|
* DetailModule.kt is part of Auxio.
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.metadata
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -25,10 +25,6 @@ import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface MetadataModule {
|
interface DetailModule {
|
||||||
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
|
@Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory
|
||||||
|
|
||||||
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
|
|
||||||
|
|
||||||
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
|
|
||||||
}
|
}
|
||||||
|
|
@ -18,43 +18,41 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.yield
|
|
||||||
import org.oxycblt.auxio.R
|
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.DiscHeader
|
||||||
import org.oxycblt.auxio.detail.list.EditHeader
|
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.detail.list.SortHeader
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
import org.oxycblt.auxio.list.BasicHeader
|
||||||
import org.oxycblt.auxio.list.Divider
|
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListSettings
|
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.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
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.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
|
||||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
|
||||||
import org.oxycblt.auxio.playback.PlaySong
|
import org.oxycblt.auxio.playback.PlaySong
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
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.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
|
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||||
|
|
@ -68,10 +66,11 @@ class DetailViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val playbackSettings: PlaybackSettings
|
detailGeneratorFactory: DetailGenerator.Factory
|
||||||
) : ViewModel(), MusicRepository.UpdateListener {
|
) : ViewModel(), DetailGenerator.Invalidator {
|
||||||
private val _toShow = MutableEvent<Show>()
|
private val _toShow = MutableEvent<Show>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||||
*/
|
*/
|
||||||
|
|
@ -80,30 +79,34 @@ constructor(
|
||||||
|
|
||||||
// --- SONG ---
|
// --- SONG ---
|
||||||
|
|
||||||
private var currentSongJob: Job? = null
|
|
||||||
|
|
||||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||||
|
|
||||||
/** The current [Song] to display. Null if there is nothing to show. */
|
/** The current [Song] to display. Null if there is nothing to show. */
|
||||||
val currentSong: StateFlow<Song?>
|
val currentSong: StateFlow<Song?>
|
||||||
get() = _currentSong
|
get() = _currentSong
|
||||||
|
|
||||||
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
|
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
|
||||||
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
|
|
||||||
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
|
/** The current properties of [currentSong]. Empty if nothing to show. */
|
||||||
|
val currentSongProperties: StateFlow<List<SongProperty>>
|
||||||
|
get() = _currentSongProperties
|
||||||
|
|
||||||
// --- ALBUM ---
|
// --- ALBUM ---
|
||||||
|
|
||||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||||
|
|
||||||
/** The current [Album] to display. Null if there is nothing to show. */
|
/** The current [Album] to display. Null if there is nothing to show. */
|
||||||
val currentAlbum: StateFlow<Album?>
|
val currentAlbum: StateFlow<Album?>
|
||||||
get() = _currentAlbum
|
get() = _currentAlbum
|
||||||
|
|
||||||
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
||||||
|
|
||||||
/** The current list data derived from [currentAlbum]. */
|
/** The current list data derived from [currentAlbum]. */
|
||||||
val albumSongList: StateFlow<List<Item>>
|
val albumSongList: StateFlow<List<Item>>
|
||||||
get() = _albumSongList
|
get() = _albumSongList
|
||||||
|
|
||||||
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
||||||
|
|
||||||
/** Instructions for updating [albumSongList] in the UI. */
|
/** Instructions for updating [albumSongList] in the UI. */
|
||||||
val albumSongInstructions: Event<UpdateInstructions>
|
val albumSongInstructions: Event<UpdateInstructions>
|
||||||
get() = _albumSongInstructions
|
get() = _albumSongInstructions
|
||||||
|
|
@ -119,27 +122,25 @@ constructor(
|
||||||
// --- ARTIST ---
|
// --- ARTIST ---
|
||||||
|
|
||||||
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
||||||
|
|
||||||
/** The current [Artist] to display. Null if there is nothing to show. */
|
/** The current [Artist] to display. Null if there is nothing to show. */
|
||||||
val currentArtist: StateFlow<Artist?>
|
val currentArtist: StateFlow<Artist?>
|
||||||
get() = _currentArtist
|
get() = _currentArtist
|
||||||
|
|
||||||
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
||||||
|
|
||||||
/** The current list derived from [currentArtist]. */
|
/** The current list derived from [currentArtist]. */
|
||||||
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
||||||
|
|
||||||
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||||
|
|
||||||
/** Instructions for updating [artistSongList] in the UI. */
|
/** Instructions for updating [artistSongList] in the UI. */
|
||||||
val artistSongInstructions: Event<UpdateInstructions>
|
val artistSongInstructions: Event<UpdateInstructions>
|
||||||
get() = _artistSongInstructions
|
get() = _artistSongInstructions
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
||||||
var artistSongSort: Sort
|
val artistSongSort: Sort
|
||||||
get() = listSettings.artistSongSort
|
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. */
|
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
||||||
val playInArtistWith
|
val playInArtistWith
|
||||||
|
|
@ -148,27 +149,25 @@ constructor(
|
||||||
// --- GENRE ---
|
// --- GENRE ---
|
||||||
|
|
||||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||||
|
|
||||||
/** The current [Genre] to display. Null if there is nothing to show. */
|
/** The current [Genre] to display. Null if there is nothing to show. */
|
||||||
val currentGenre: StateFlow<Genre?>
|
val currentGenre: StateFlow<Genre?>
|
||||||
get() = _currentGenre
|
get() = _currentGenre
|
||||||
|
|
||||||
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
||||||
|
|
||||||
/** The current list data derived from [currentGenre]. */
|
/** The current list data derived from [currentGenre]. */
|
||||||
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
||||||
|
|
||||||
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
||||||
|
|
||||||
/** Instructions for updating [artistSongList] in the UI. */
|
/** Instructions for updating [artistSongList] in the UI. */
|
||||||
val genreSongInstructions: Event<UpdateInstructions>
|
val genreSongInstructions: Event<UpdateInstructions>
|
||||||
get() = _genreSongInstructions
|
get() = _genreSongInstructions
|
||||||
|
|
||||||
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
||||||
var genreSongSort: Sort
|
val genreSongSort: Sort
|
||||||
get() = listSettings.genreSongSort
|
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. */
|
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||||
val playInGenreWith
|
val playInGenreWith
|
||||||
|
|
@ -177,20 +176,24 @@ constructor(
|
||||||
// --- PLAYLIST ---
|
// --- PLAYLIST ---
|
||||||
|
|
||||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||||
|
|
||||||
/** The current [Playlist] to display. Null if there is nothing to do. */
|
/** The current [Playlist] to display. Null if there is nothing to do. */
|
||||||
val currentPlaylist: StateFlow<Playlist?>
|
val currentPlaylist: StateFlow<Playlist?>
|
||||||
get() = _currentPlaylist
|
get() = _currentPlaylist
|
||||||
|
|
||||||
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
||||||
|
|
||||||
/** The current list data derived from [currentPlaylist] */
|
/** The current list data derived from [currentPlaylist] */
|
||||||
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
|
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
|
||||||
|
|
||||||
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
|
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||||
|
|
||||||
/** Instructions for updating [playlistSongList] in the UI. */
|
/** Instructions for updating [playlistSongList] in the UI. */
|
||||||
val playlistSongInstructions: Event<UpdateInstructions>
|
val playlistSongInstructions: Event<UpdateInstructions>
|
||||||
get() = _playlistSongInstructions
|
get() = _playlistSongInstructions
|
||||||
|
|
||||||
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The new playlist songs created during the current editing session. Null if no editing session
|
* The new playlist songs created during the current editing session. Null if no editing session
|
||||||
* is occurring.
|
* is occurring.
|
||||||
|
|
@ -204,54 +207,35 @@ constructor(
|
||||||
playbackSettings.inParentPlaybackMode
|
playbackSettings.inParentPlaybackMode
|
||||||
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
||||||
|
|
||||||
|
private val detailGenerator = detailGeneratorFactory.create(this)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addUpdateListener(this)
|
detailGenerator.attach()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicRepository.removeUpdateListener(this)
|
detailGenerator.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun invalidate(type: MusicType, replace: Int?) {
|
||||||
// If we are showing any item right now, we will need to refresh it (and any information
|
when (type) {
|
||||||
// related to it) with the new library in order to prevent stale items from showing up
|
MusicType.ALBUMS -> {
|
||||||
// in the UI.
|
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.ARTISTS -> {
|
||||||
val album = currentAlbum.value
|
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
|
||||||
if (album != null) {
|
refreshDetail(
|
||||||
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
|
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
|
||||||
logD("Updated album to ${currentAlbum.value}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.GENRES -> {
|
||||||
val artist = currentArtist.value
|
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
|
||||||
if (artist != null) {
|
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
|
||||||
_currentArtist.value =
|
|
||||||
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
|
|
||||||
logD("Updated artist to ${currentArtist.value}")
|
|
||||||
}
|
}
|
||||||
|
MusicType.PLAYLISTS -> {
|
||||||
val genre = currentGenre.value
|
refreshPlaylist(currentPlaylist.value?.uid ?: return)
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
|
else -> error("Unexpected music type $type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,23 +312,23 @@ constructor(
|
||||||
private fun showImpl(show: Show) {
|
private fun showImpl(show: Show) {
|
||||||
val existing = toShow.flow.value
|
val existing = toShow.flow.value
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
logD("Already have pending show command $existing, ignoring $show")
|
L.d("Already have pending show command $existing, ignoring $show")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_toShow.put(show)
|
_toShow.put(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
|
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
|
||||||
* be updated to align with the new [Song].
|
* the new [Song].
|
||||||
*
|
*
|
||||||
* @param uid The UID of the [Song] to load. Must be valid.
|
* @param uid The UID of the [Song] to load. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setSong(uid: Music.UID) {
|
fun setSong(uid: Music.UID) {
|
||||||
logD("Opening song $uid")
|
L.d("Opening song $uid")
|
||||||
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
|
||||||
if (_currentSong.value == null) {
|
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.
|
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setAlbum(uid: Music.UID) {
|
fun setAlbum(uid: Music.UID) {
|
||||||
logD("Opening album $uid")
|
L.d("Opening album $uid")
|
||||||
_currentAlbum.value =
|
if (uid === _currentAlbum.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
|
return
|
||||||
|
}
|
||||||
|
val album = detailGenerator.album(uid)
|
||||||
|
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
|
||||||
if (_currentAlbum.value == 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) {
|
fun applyAlbumSongSort(sort: Sort) {
|
||||||
listSettings.albumSongSort = 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.
|
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setArtist(uid: Music.UID) {
|
fun setArtist(uid: Music.UID) {
|
||||||
logD("Opening artist $uid")
|
L.d("Opening artist $uid")
|
||||||
_currentArtist.value =
|
if (uid === _currentArtist.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
|
return
|
||||||
if (_currentArtist.value == null) {
|
|
||||||
logW("Given artist UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
val artist = detailGenerator.artist(uid)
|
||||||
|
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -395,7 +381,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyArtistSongSort(sort: Sort) {
|
fun applyArtistSongSort(sort: Sort) {
|
||||||
listSettings.artistSongSort = 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.
|
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setGenre(uid: Music.UID) {
|
fun setGenre(uid: Music.UID) {
|
||||||
logD("Opening genre $uid")
|
L.d("Opening genre $uid")
|
||||||
_currentGenre.value =
|
if (uid === _currentGenre.value?.uid) {
|
||||||
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
|
return
|
||||||
if (_currentGenre.value == null) {
|
|
||||||
logW("Given genre UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
val genre = detailGenerator.genre(uid)
|
||||||
|
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -420,7 +405,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applyGenreSongSort(sort: Sort) {
|
fun applyGenreSongSort(sort: Sort) {
|
||||||
listSettings.genreSongSort = 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.
|
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
||||||
*/
|
*/
|
||||||
fun setPlaylist(uid: Music.UID) {
|
fun setPlaylist(uid: Music.UID) {
|
||||||
logD("Opening playlist $uid")
|
L.d("Opening playlist $uid")
|
||||||
_currentPlaylist.value =
|
if (uid === _currentPlaylist.value?.uid) {
|
||||||
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
|
return
|
||||||
if (_currentPlaylist.value == null) {
|
|
||||||
logW("Given playlist UID was invalid")
|
|
||||||
}
|
}
|
||||||
|
refreshPlaylist(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||||
fun startPlaylistEdit() {
|
fun startPlaylistEdit() {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
logD("Starting playlist edit")
|
L.d("Starting playlist edit")
|
||||||
_editedPlaylist.value = playlist.songs
|
_editedPlaylist.value = playlist.songs
|
||||||
refreshPlaylistList(playlist)
|
refreshPlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -453,12 +436,13 @@ constructor(
|
||||||
fun savePlaylistEdit() {
|
fun savePlaylistEdit() {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
val editedPlaylist = _editedPlaylist.value ?: return
|
val editedPlaylist = _editedPlaylist.value ?: return
|
||||||
logD("Committing playlist edits")
|
L.d("Committing playlist edits")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
||||||
// TODO: The user could probably press some kind of button if they were fast enough.
|
// TODO: The user could probably press some kind of button if they were fast enough.
|
||||||
// Think of a better way to handle this state.
|
// Think of a better way to handle this state.
|
||||||
_editedPlaylist.value = null
|
_editedPlaylist.value = null
|
||||||
|
refreshPlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,9 +458,8 @@ constructor(
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
logD("Discarding playlist edits")
|
|
||||||
_editedPlaylist.value = null
|
_editedPlaylist.value = null
|
||||||
refreshPlaylistList(playlist)
|
refreshPlaylist(playlist.uid)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,7 +471,7 @@ constructor(
|
||||||
fun applyPlaylistSongSort(sort: Sort) {
|
fun applyPlaylistSongSort(sort: Sort) {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
_editedPlaylist.value = sort.songs(_editedPlaylist.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 {
|
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||||
val playlist = _currentPlaylist.value ?: return false
|
val playlist = _currentPlaylist.value ?: return false
|
||||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||||
val realFrom = from - 2
|
val realFrom = from - 1
|
||||||
val realTo = to - 2
|
val realTo = to - 1
|
||||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||||
return false
|
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.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
|
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -521,203 +504,134 @@ constructor(
|
||||||
fun removePlaylistSong(at: Int) {
|
fun removePlaylistSong(at: Int) {
|
||||||
val playlist = _currentPlaylist.value ?: return
|
val playlist = _currentPlaylist.value ?: return
|
||||||
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
||||||
val realAt = at - 2
|
val realAt = at - 1
|
||||||
if (realAt !in editedPlaylist.indices) {
|
if (realAt !in editedPlaylist.indices) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Removing playlist song at $realAt [$at]")
|
L.d("Removing playlist song at $realAt [$at]")
|
||||||
editedPlaylist.removeAt(realAt)
|
editedPlaylist.removeAt(realAt)
|
||||||
_editedPlaylist.value = editedPlaylist
|
_editedPlaylist.value = editedPlaylist
|
||||||
refreshPlaylistList(
|
refreshPlaylist(
|
||||||
playlist,
|
playlist.uid,
|
||||||
if (editedPlaylist.isNotEmpty()) {
|
if (editedPlaylist.isNotEmpty()) {
|
||||||
UpdateInstructions.Remove(at, 1)
|
UpdateInstructions.Remove(at, 1)
|
||||||
} else {
|
} else {
|
||||||
logD("Playlist will be empty after removal, removing header")
|
L.d("Playlist will be empty after removal, removing header")
|
||||||
UpdateInstructions.Remove(at - 2, 3)
|
UpdateInstructions.Remove(at - 1, 3)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAudioInfo(song: Song) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
logD("Refreshing audio info")
|
_currentSongProperties.value = buildList {
|
||||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
|
||||||
currentSongJob?.cancel()
|
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
|
||||||
_songAudioProperties.value = null
|
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
|
||||||
currentSongJob =
|
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
|
||||||
val info = audioPropertiesFactory.extract(song)
|
song.track?.let {
|
||||||
yield()
|
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
|
||||||
logD("Updating audio info to $info")
|
|
||||||
_songAudioProperties.value = info
|
|
||||||
}
|
}
|
||||||
}
|
song.disc?.let {
|
||||||
|
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
|
||||||
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(Divider(header))
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
|
||||||
// To create a good user experience regarding disc numbers, we group the album's
|
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
|
||||||
// songs up by disc and then delimit the groups by a disc header.
|
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
|
||||||
val songs = albumSongSort.songs(album.songs)
|
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
|
||||||
val byDisc = songs.groupBy { it.disc }
|
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
|
||||||
if (byDisc.size > 1) {
|
add(
|
||||||
logD("Album has more than one disc, interspersing headers")
|
SongProperty(
|
||||||
for (entry in byDisc.entries) {
|
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
|
||||||
list.add(DiscHeader(entry.key))
|
song.replayGainAdjustment.track?.let {
|
||||||
list.addAll(entry.value)
|
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) {
|
private inline fun <T : MusicParent> refreshDetail(
|
||||||
logD("Refreshing artist list")
|
detail: Detail<T>?,
|
||||||
val list = mutableListOf<Item>()
|
parent: MutableStateFlow<T?>,
|
||||||
|
list: MutableStateFlow<List<Item>>,
|
||||||
val grouping =
|
instructions: MutableEvent<UpdateInstructions>,
|
||||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
replace: Int?,
|
||||||
// Remap the complicated ReleaseType data structure into an easier
|
songHeader: (Int) -> PlainHeader = { SortHeader(it) }
|
||||||
// "AlbumGrouping" enum that will automatically group and sort
|
) {
|
||||||
// the artist's albums.
|
if (detail == null) {
|
||||||
when (it.releaseType.refinement) {
|
parent.value = null
|
||||||
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
|
return
|
||||||
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
|
}
|
||||||
null ->
|
val newList = mutableListOf<Item>()
|
||||||
when (it.releaseType) {
|
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
is ReleaseType.Album -> AlbumGrouping.ALBUMS
|
for ((i, section) in detail.sections.withIndex()) {
|
||||||
is ReleaseType.EP -> AlbumGrouping.EPS
|
val items =
|
||||||
is ReleaseType.Single -> AlbumGrouping.SINGLES
|
when (section) {
|
||||||
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
|
is DetailSection.PlainSection<*> -> {
|
||||||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
val header =
|
||||||
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
if (section is DetailSection.Songs) songHeader(section.stringRes)
|
||||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||||
|
// and thus need to be replaced.
|
||||||
if (artist.implicitAlbums.isNotEmpty()) {
|
if (replace == -1 && i == detail.sections.lastIndex) {
|
||||||
// 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 (entry in grouping.entries) {
|
|
||||||
val header = BasicHeader(entry.key.headerTitleRes)
|
|
||||||
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) {
|
|
||||||
// Intentional so that the header item isn't replaced with the songs
|
// 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)
|
||||||
}
|
}
|
||||||
|
parent.value = detail.parent
|
||||||
logD("Updating artist list to ${list.size} items with $instructions")
|
instructions.put(newInstructions)
|
||||||
_artistSongInstructions.put(instructions)
|
list.value = newList
|
||||||
_artistSongList.value = list.toList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
private fun refreshPlaylist(
|
||||||
logD("Refreshing genre list")
|
uid: Music.UID,
|
||||||
val list = mutableListOf<Item>()
|
|
||||||
// Genre is guaranteed to always have artists and songs.
|
|
||||||
val artistHeader = BasicHeader(R.string.lbl_artists)
|
|
||||||
list.add(Divider(artistHeader))
|
|
||||||
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,
|
|
||||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||||
) {
|
) {
|
||||||
logD("Refreshing playlist list")
|
L.d("Refreshing playlist list")
|
||||||
val list = mutableListOf<Item>()
|
val edited = editedPlaylist.value
|
||||||
|
if (edited == null) {
|
||||||
val songs = editedPlaylist.value ?: playlist.songs
|
val playlist = detailGenerator.playlist(uid)
|
||||||
if (songs.isNotEmpty()) {
|
refreshDetail(
|
||||||
val header = EditHeader(R.string.lbl_songs)
|
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) {
|
||||||
list.add(Divider(header))
|
EditHeader(it)
|
||||||
list.add(header)
|
}
|
||||||
list.addAll(songs)
|
return
|
||||||
|
}
|
||||||
|
val list = mutableListOf<Item>()
|
||||||
|
if (edited.isNotEmpty()) {
|
||||||
|
val header = EditHeader(R.string.lbl_songs)
|
||||||
|
list.add(header)
|
||||||
|
list.addAll(edited)
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
|
||||||
_playlistSongInstructions.put(instructions)
|
_playlistSongInstructions.put(instructions)
|
||||||
_playlistSongList.value = list
|
_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),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,42 +19,32 @@
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
|
||||||
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
||||||
import org.oxycblt.auxio.list.Divider
|
|
||||||
import org.oxycblt.auxio.list.Header
|
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
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.MusicViewModel
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
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].
|
* A [ListFragment] that shows information for a particular [Genre].
|
||||||
|
|
@ -62,70 +52,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class GenreDetailFragment :
|
class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
||||||
ListFragment<Music, FragmentDetailBinding>(),
|
|
||||||
DetailHeaderAdapter.Listener,
|
|
||||||
DetailListAdapter.Listener<Music> {
|
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
|
||||||
override val listModel: ListViewModel by activityViewModels()
|
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
|
||||||
// Information about what genre to display is initially within the navigation arguments
|
// Information about what genre to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an genre.
|
// as a UID, as that is the only safe way to parcel an genre.
|
||||||
private val args: GenreDetailFragmentArgs by navArgs()
|
private val args: GenreDetailFragmentArgs by navArgs()
|
||||||
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
|
|
||||||
private val genreListAdapter = GenreDetailListAdapter(this)
|
private val genreListAdapter = GenreDetailListAdapter(this)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun getDetailListAdapter() = genreListAdapter
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
|
||||||
|
|
||||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
|
||||||
binding.detailSelectionToolbar
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
|
||||||
binding.detailNormalToolbar.apply {
|
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
|
||||||
setOnMenuItemClickListener(this@GenreDetailFragment)
|
|
||||||
overrideOnOverflowMenuClick {
|
|
||||||
listModel.openMenu(
|
|
||||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailRecycler.apply {
|
|
||||||
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
|
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
|
||||||
if (it != 0) {
|
|
||||||
val item =
|
|
||||||
detailModel.genreSongList.value.getOrElse(it - 1) {
|
|
||||||
return@setFullWidthLookup false
|
|
||||||
}
|
|
||||||
item is Divider || item is Header
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setGenre(args.genreUid)
|
detailModel.setGenre(args.genreUid)
|
||||||
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
|
collectImmediately(detailModel.currentGenre, ::updateGenre)
|
||||||
collectImmediately(detailModel.genreSongList, ::updateList)
|
collectImmediately(detailModel.genreSongList, ::updateList)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||||
|
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||||
|
|
@ -133,8 +80,6 @@ class GenreDetailFragment :
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||||
super.onDestroyBinding(binding)
|
super.onDestroyBinding(binding)
|
||||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
|
||||||
binding.detailRecycler.adapter = null
|
|
||||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||||
detailModel.genreSongInstructions.consume()
|
detailModel.genreSongInstructions.consume()
|
||||||
|
|
@ -148,6 +93,10 @@ class GenreDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenParentMenu() {
|
||||||
|
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Music) {
|
override fun onOpenMenu(item: Music) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is Artist -> listModel.openMenu(R.menu.parent, item)
|
is Artist -> listModel.openMenu(R.menu.parent, item)
|
||||||
|
|
@ -156,26 +105,45 @@ class GenreDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlay() {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffle() {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpenSortMenu() {
|
override fun onOpenSortMenu() {
|
||||||
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
|
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaylist(genre: Genre?) {
|
private fun updateGenre(genre: Genre?) {
|
||||||
if (genre == null) {
|
if (genre == null) {
|
||||||
logD("No genre to show, navigating away")
|
L.d("No genre to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
|
val binding = requireBinding()
|
||||||
genreHeaderAdapter.setParent(genre)
|
val context = requireContext()
|
||||||
|
val name = genre.name.resolve(context)
|
||||||
|
binding.detailToolbarTitle.text = name
|
||||||
|
binding.detailCover.bind(genre)
|
||||||
|
binding.detailType.text = context.getString(R.string.lbl_genre)
|
||||||
|
binding.detailName.text = genre.name.resolve(context)
|
||||||
|
// Nothing about a genre is applicable to the sub-head text.
|
||||||
|
binding.detailSubhead.isVisible = false
|
||||||
|
// The song and artist count of the genre maps to the info text.
|
||||||
|
binding.detailInfo.text =
|
||||||
|
context.getString(
|
||||||
|
R.string.fmt_two,
|
||||||
|
context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
||||||
|
context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
||||||
|
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>) {
|
private fun updateList(list: List<Item>) {
|
||||||
|
|
@ -185,7 +153,7 @@ class GenreDetailFragment :
|
||||||
private fun handleShow(show: Show?) {
|
private fun handleShow(show: Show?) {
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongDetails -> {
|
is Show.SongDetails -> {
|
||||||
logD("Navigating to ${show.song}")
|
L.d("Navigating to ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +161,7 @@ class GenreDetailFragment :
|
||||||
// Songs should be scrolled to if the album matches, or a new detail
|
// Songs should be scrolled to if the album matches, or a new detail
|
||||||
// fragment should be launched otherwise.
|
// fragment should be launched otherwise.
|
||||||
is Show.SongAlbumDetails -> {
|
is Show.SongAlbumDetails -> {
|
||||||
logD("Navigating to the album of ${show.song}")
|
L.d("Navigating to the album of ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -201,29 +169,29 @@ class GenreDetailFragment :
|
||||||
// If the album matches, no need to do anything. Otherwise launch a new
|
// If the album matches, no need to do anything. Otherwise launch a new
|
||||||
// detail fragment.
|
// detail fragment.
|
||||||
is Show.AlbumDetails -> {
|
is Show.AlbumDetails -> {
|
||||||
logD("Navigating to ${show.album}")
|
L.d("Navigating to ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always launch a new ArtistDetailFragment.
|
// Always launch a new ArtistDetailFragment.
|
||||||
is Show.ArtistDetails -> {
|
is Show.ArtistDetails -> {
|
||||||
logD("Navigating to ${show.artist}")
|
L.d("Navigating to ${show.artist}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
|
||||||
}
|
}
|
||||||
is Show.SongArtistDecision -> {
|
is Show.SongArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.song}")
|
L.d("Navigating to artist choices for ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumArtistDecision -> {
|
is Show.AlbumArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.album}")
|
L.d("Navigating to artist choices for ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
|
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||||
}
|
}
|
||||||
is Show.GenreDetails -> {
|
is Show.GenreDetails -> {
|
||||||
logD("Navigated to this genre")
|
L.d("Navigated to this genre")
|
||||||
detailModel.toShow.consume()
|
detailModel.toShow.consume()
|
||||||
}
|
}
|
||||||
is Show.PlaylistDetails -> {
|
is Show.PlaylistDetails -> {
|
||||||
|
|
@ -259,22 +227,30 @@ class GenreDetailFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDecision(decision: PlaylistDecision?) {
|
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||||
if (decision == null) return
|
if (decision == null) return
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaylistDecision.Add -> {
|
is PlaylistDecision.Add -> {
|
||||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||||
GenreDetailFragmentDirections.addToPlaylist(
|
GenreDetailFragmentDirections.addToPlaylist(
|
||||||
decision.songs.map { it.uid }.toTypedArray())
|
decision.songs.map { it.uid }.toTypedArray())
|
||||||
}
|
}
|
||||||
is PlaylistDecision.New,
|
is PlaylistDecision.New,
|
||||||
|
is PlaylistDecision.Import,
|
||||||
is PlaylistDecision.Rename,
|
is PlaylistDecision.Rename,
|
||||||
|
is PlaylistDecision.Export,
|
||||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||||
}
|
}
|
||||||
findNavController().navigateSafe(directions)
|
findNavController().navigateSafe(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||||
|
if (message == null) return
|
||||||
|
requireContext().showToast(message.stringRes)
|
||||||
|
musicModel.playlistMessage.consume()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
||||||
val playingItem =
|
val playingItem =
|
||||||
|
|
@ -295,7 +271,7 @@ class GenreDetailFragment :
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaybackDecision.PlayFromArtist -> {
|
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)
|
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||||
}
|
}
|
||||||
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")
|
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")
|
||||||
|
|
|
||||||
|
|
@ -19,45 +19,41 @@
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.ConcatAdapter
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
|
|
||||||
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
|
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
|
||||||
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
||||||
import org.oxycblt.auxio.list.Divider
|
|
||||||
import org.oxycblt.auxio.list.Header
|
|
||||||
import org.oxycblt.auxio.list.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
import org.oxycblt.auxio.list.menu.Menu
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.context
|
||||||
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.showToast
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
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].
|
* A [ListFragment] that shows information for a particular [Playlist].
|
||||||
|
|
@ -66,83 +62,58 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class PlaylistDetailFragment :
|
class PlaylistDetailFragment :
|
||||||
ListFragment<Song, FragmentDetailBinding>(),
|
DetailFragment<Playlist, Song>(), PlaylistDetailListAdapter.Listener {
|
||||||
DetailHeaderAdapter.Listener,
|
|
||||||
PlaylistDetailListAdapter.Listener {
|
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
|
||||||
override val listModel: ListViewModel by activityViewModels()
|
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
|
||||||
// Information about what playlist to display is initially within the navigation arguments
|
// Information about what playlist to display is initially within the navigation arguments
|
||||||
// as a UID, as that is the only safe way to parcel an playlist.
|
// as a UID, as that is the only safe way to parcel an playlist.
|
||||||
private val args: PlaylistDetailFragmentArgs by navArgs()
|
private val args: PlaylistDetailFragmentArgs by navArgs()
|
||||||
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
|
|
||||||
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
||||||
private var touchHelper: ItemTouchHelper? = null
|
private var touchHelper: ItemTouchHelper? = null
|
||||||
private var editNavigationListener: DialogAwareNavigationListener? = null
|
private var editNavigationListener: DialogAwareNavigationListener? = null
|
||||||
|
private var getContentLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
private var pendingImportTarget: Playlist? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun getDetailListAdapter() = playlistListAdapter
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
|
||||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
|
||||||
|
|
||||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
|
||||||
binding.detailSelectionToolbar
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
|
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
|
||||||
|
|
||||||
// --- UI SETUP ---
|
getContentLauncher =
|
||||||
binding.detailNormalToolbar.apply {
|
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
if (uri == null) {
|
||||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
L.w("No URI returned from file picker")
|
||||||
overrideOnOverflowMenuClick {
|
return@registerForActivityResult
|
||||||
listModel.openMenu(
|
}
|
||||||
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
|
||||||
|
L.d("Received playlist URI $uri")
|
||||||
|
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// --- UI SETUP ---
|
||||||
|
|
||||||
binding.detailEditToolbar.apply {
|
binding.detailEditToolbar.apply {
|
||||||
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
|
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
|
||||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.detailRecycler.apply {
|
touchHelper =
|
||||||
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
|
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
|
||||||
touchHelper =
|
it.attachToRecyclerView(binding.detailRecycler)
|
||||||
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
|
|
||||||
it.attachToRecyclerView(this)
|
|
||||||
}
|
|
||||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
|
||||||
if (it != 0) {
|
|
||||||
val item =
|
|
||||||
detailModel.playlistSongList.value.getOrElse(it - 1) {
|
|
||||||
return@setFullWidthLookup false
|
|
||||||
}
|
|
||||||
item is Divider || item is Header
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setPlaylist(args.playlistUid)
|
detailModel.setPlaylist(args.playlistUid)
|
||||||
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
collectImmediately(
|
||||||
|
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
|
||||||
collectImmediately(detailModel.playlistSongList, ::updateList)
|
collectImmediately(detailModel.playlistSongList, ::updateList)
|
||||||
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
|
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||||
|
collect(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||||
|
|
@ -190,41 +161,97 @@ class PlaylistDetailFragment :
|
||||||
playbackModel.play(item, detailModel.playInPlaylistWith)
|
playbackModel.play(item, detailModel.playInPlaylistWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStartEdit() {
|
||||||
|
detailModel.startPlaylistEdit()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||||
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onOpenParentMenu() {
|
||||||
|
listModel.openMenu(
|
||||||
|
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOpenMenu(item: Song) {
|
override fun onOpenMenu(item: Song) {
|
||||||
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
|
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPlay() {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onShuffle() {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartEdit() {
|
|
||||||
detailModel.startPlaylistEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOpenSortMenu() {
|
override fun onOpenSortMenu() {
|
||||||
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
|
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePlaylist(playlist: Playlist?) {
|
private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) {
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
// Playlist we were showing no longer exists.
|
// Playlist we were showing no longer exists.
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
|
binding.detailToolbarTitle.text = playlist.name.resolve(requireContext())
|
||||||
binding.detailEditToolbar.title =
|
binding.detailEditToolbar.title =
|
||||||
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
||||||
playlistHeaderAdapter.setParent(playlist)
|
|
||||||
|
if (editedPlaylist != null) {
|
||||||
|
L.d("Binding edited playlist image")
|
||||||
|
binding.detailCover.bind(
|
||||||
|
editedPlaylist,
|
||||||
|
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
||||||
|
R.drawable.ic_playlist_24)
|
||||||
|
} else {
|
||||||
|
binding.detailCover.bind(playlist)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
||||||
|
binding.detailName.text = playlist.name.resolve(binding.context)
|
||||||
|
// Nothing about a playlist is applicable to the sub-head text.
|
||||||
|
binding.detailSubhead.isVisible = false
|
||||||
|
|
||||||
|
val songs = editedPlaylist ?: playlist.songs
|
||||||
|
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
|
||||||
|
// The song count of the playlist maps to the info text.
|
||||||
|
binding.detailInfo.text =
|
||||||
|
if (songs.isNotEmpty()) {
|
||||||
|
binding.context.getString(
|
||||||
|
R.string.fmt_two,
|
||||||
|
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
|
||||||
|
durationMs.formatDurationMs(true))
|
||||||
|
} else {
|
||||||
|
binding.context.getString(R.string.def_song_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||||
|
if (!playable) {
|
||||||
|
L.d("Playlist is being edited or is empty, disabling playback options")
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.detailPlayButton?.apply {
|
||||||
|
isEnabled = playable
|
||||||
|
setOnClickListener {
|
||||||
|
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>) {
|
private fun updateList(list: List<Item>) {
|
||||||
|
|
@ -233,11 +260,10 @@ class PlaylistDetailFragment :
|
||||||
|
|
||||||
private fun updateEditedList(editedPlaylist: List<Song>?) {
|
private fun updateEditedList(editedPlaylist: List<Song>?) {
|
||||||
playlistListAdapter.setEditing(editedPlaylist != null)
|
playlistListAdapter.setEditing(editedPlaylist != null)
|
||||||
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
|
|
||||||
listModel.dropSelection()
|
listModel.dropSelection()
|
||||||
|
|
||||||
if (editedPlaylist != null) {
|
if (editedPlaylist != null) {
|
||||||
logD("Updating save button state")
|
L.d("Updating save button state")
|
||||||
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
||||||
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
||||||
}
|
}
|
||||||
|
|
@ -249,38 +275,38 @@ class PlaylistDetailFragment :
|
||||||
private fun handleShow(show: Show?) {
|
private fun handleShow(show: Show?) {
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongDetails -> {
|
is Show.SongDetails -> {
|
||||||
logD("Navigating to ${show.song}")
|
L.d("Navigating to ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
|
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.SongAlbumDetails -> {
|
is Show.SongAlbumDetails -> {
|
||||||
logD("Navigating to the album of ${show.song}")
|
L.d("Navigating to the album of ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumDetails -> {
|
is Show.AlbumDetails -> {
|
||||||
logD("Navigating to ${show.album}")
|
L.d("Navigating to ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
|
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||||
}
|
}
|
||||||
is Show.ArtistDetails -> {
|
is Show.ArtistDetails -> {
|
||||||
logD("Navigating to ${show.artist}")
|
L.d("Navigating to ${show.artist}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
|
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||||
}
|
}
|
||||||
is Show.SongArtistDecision -> {
|
is Show.SongArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.song}")
|
L.d("Navigating to artist choices for ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumArtistDecision -> {
|
is Show.AlbumArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.album}")
|
L.d("Navigating to artist choices for ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(
|
.navigateSafe(
|
||||||
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||||
}
|
}
|
||||||
is Show.PlaylistDetails -> {
|
is Show.PlaylistDetails -> {
|
||||||
logD("Navigated to this playlist")
|
L.d("Navigated to this playlist")
|
||||||
detailModel.toShow.consume()
|
detailModel.toShow.consume()
|
||||||
}
|
}
|
||||||
is Show.GenreDetails -> {
|
is Show.GenreDetails -> {
|
||||||
|
|
@ -316,24 +342,52 @@ class PlaylistDetailFragment :
|
||||||
updateMultiToolbar()
|
updateMultiToolbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDecision(decision: PlaylistDecision?) {
|
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||||
if (decision == null) return
|
if (decision == null) return
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
|
is PlaylistDecision.Import -> {
|
||||||
|
L.d("Importing playlist")
|
||||||
|
pendingImportTarget = decision.target
|
||||||
|
requireNotNull(getContentLauncher) {
|
||||||
|
"Content picker launcher was not available"
|
||||||
|
}
|
||||||
|
.launch(M3U.MIME_TYPE)
|
||||||
|
musicModel.playlistDecision.consume()
|
||||||
|
return
|
||||||
|
}
|
||||||
is PlaylistDecision.Rename -> {
|
is PlaylistDecision.Rename -> {
|
||||||
logD("Renaming ${decision.playlist}")
|
L.d("Renaming ${decision.playlist}")
|
||||||
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)
|
PlaylistDetailFragmentDirections.renamePlaylist(
|
||||||
|
decision.playlist.uid,
|
||||||
|
decision.template,
|
||||||
|
decision.applySongs.map { it.uid }.toTypedArray(),
|
||||||
|
decision.reason)
|
||||||
|
}
|
||||||
|
is PlaylistDecision.Export -> {
|
||||||
|
L.d("Exporting ${decision.playlist}")
|
||||||
|
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||||
}
|
}
|
||||||
is PlaylistDecision.Delete -> {
|
is PlaylistDecision.Delete -> {
|
||||||
logD("Deleting ${decision.playlist}")
|
L.d("Deleting ${decision.playlist}")
|
||||||
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
|
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||||
}
|
}
|
||||||
is PlaylistDecision.Add,
|
is PlaylistDecision.Add -> {
|
||||||
|
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||||
|
PlaylistDetailFragmentDirections.addToPlaylist(
|
||||||
|
decision.songs.map { it.uid }.toTypedArray())
|
||||||
|
}
|
||||||
is PlaylistDecision.New -> error("Unexpected playlist decision $decision")
|
is PlaylistDecision.New -> error("Unexpected playlist decision $decision")
|
||||||
}
|
}
|
||||||
findNavController().navigateSafe(directions)
|
findNavController().navigateSafe(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||||
|
if (message == null) return
|
||||||
|
requireContext().showToast(message.stringRes)
|
||||||
|
musicModel.playlistMessage.consume()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||||
// Prefer songs that are playing from this playlist.
|
// Prefer songs that are playing from this playlist.
|
||||||
playlistListAdapter.setPlaying(
|
playlistListAdapter.setPlaying(
|
||||||
|
|
@ -345,11 +399,11 @@ class PlaylistDetailFragment :
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaybackDecision.PlayFromArtist -> {
|
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)
|
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||||
}
|
}
|
||||||
is PlaybackDecision.PlayFromGenre -> {
|
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)
|
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,15 +414,15 @@ class PlaylistDetailFragment :
|
||||||
val id =
|
val id =
|
||||||
when {
|
when {
|
||||||
detailModel.editedPlaylist.value != null -> {
|
detailModel.editedPlaylist.value != null -> {
|
||||||
logD("Currently editing playlist, showing edit toolbar")
|
L.d("Currently editing playlist, showing edit toolbar")
|
||||||
R.id.detail_edit_toolbar
|
R.id.detail_edit_toolbar
|
||||||
}
|
}
|
||||||
listModel.selected.value.isNotEmpty() -> {
|
listModel.selected.value.isNotEmpty() -> {
|
||||||
logD("Currently selecting, showing selection toolbar")
|
L.d("Currently selecting, showing selection toolbar")
|
||||||
R.id.detail_selection_toolbar
|
R.id.detail_selection_toolbar
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logD("Using normal toolbar")
|
L.d("Using normal toolbar")
|
||||||
R.id.detail_normal_toolbar
|
R.id.detail_normal_toolbar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,7 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.detail
|
package org.oxycblt.auxio.detail
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.format.Formatter
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
|
@ -32,16 +30,10 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
||||||
import org.oxycblt.auxio.detail.list.SongProperty
|
import org.oxycblt.auxio.detail.list.SongProperty
|
||||||
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
|
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
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.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.concatLocalized
|
import org.oxycblt.musikr.Song
|
||||||
import org.oxycblt.auxio.util.logD
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
|
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
|
||||||
|
|
@ -70,71 +62,19 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
|
||||||
// DetailViewModel handles most initialization from the navigation argument.
|
// DetailViewModel handles most initialization from the navigation argument.
|
||||||
detailModel.setSong(args.songUid)
|
detailModel.setSong(args.songUid)
|
||||||
detailModel.toShow.consume()
|
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) {
|
if (song == null) {
|
||||||
logD("No song to show, navigating away")
|
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
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_file_name, song.path.name))
|
|
||||||
add(
|
|
||||||
SongProperty(
|
|
||||||
R.string.lbl_relative_path, song.path.parent.resolveName(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)))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
UpdateInstructions.Replace(0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Music> T.zipName(context: Context): String {
|
private fun updateSongProperties(songProperties: List<SongProperty>) {
|
||||||
val name = name
|
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
|
||||||
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 <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.FlexibleListAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
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.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.musikr.Artist
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with
|
* 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 javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.musikr.Album
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.musikr.Artist
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.musikr.Library
|
||||||
import org.oxycblt.auxio.util.logW
|
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
|
* 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) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (!changes.deviceLibrary) return
|
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.
|
// Need to sanitize different items depending on the current set of choices.
|
||||||
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
|
_artistChoices.value = _artistChoices.value?.sanitize(library)
|
||||||
logD("Updated artist choices: ${_artistChoices.value}")
|
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].
|
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
||||||
*/
|
*/
|
||||||
fun setArtistChoiceUid(itemUid: Music.UID) {
|
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.
|
// Support Songs and Albums, which have parent artists.
|
||||||
_artistChoices.value =
|
_artistChoices.value =
|
||||||
when (val music = musicRepository.find(itemUid)) {
|
when (val music = musicRepository.find(itemUid)) {
|
||||||
is Song -> {
|
is Song -> {
|
||||||
logD("Creating navigation choices for song")
|
L.d("Creating navigation choices for song")
|
||||||
ArtistShowChoices.FromSong(music)
|
ArtistShowChoices.FromSong(music)
|
||||||
}
|
}
|
||||||
is Album -> {
|
is Album -> {
|
||||||
logD("Creating navigation choices for album")
|
L.d("Creating navigation choices for album")
|
||||||
ArtistShowChoices.FromAlbum(music)
|
ArtistShowChoices.FromAlbum(music)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logW("Given song/album UID was invalid")
|
L.w("Given song/album UID was invalid")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,16 +98,15 @@ sealed interface ArtistShowChoices {
|
||||||
val uid: Music.UID
|
val uid: Music.UID
|
||||||
/** The current [Artist] choices. */
|
/** The current [Artist] choices. */
|
||||||
val choices: List<Artist>
|
val choices: List<Artist>
|
||||||
/** Sanitize this instance with a [DeviceLibrary]. */
|
/** Sanitize this instance with a [Library]. */
|
||||||
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
|
fun sanitize(newLibrary: Library): ArtistShowChoices?
|
||||||
|
|
||||||
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
|
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
|
||||||
class FromSong(val song: Song) : ArtistShowChoices {
|
class FromSong(val song: Song) : ArtistShowChoices {
|
||||||
override val uid = song.uid
|
override val uid = song.uid
|
||||||
override val choices = song.artists
|
override val choices = song.artists
|
||||||
|
|
||||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||||
newLibrary.findSong(uid)?.let { FromSong(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
|
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
|
||||||
|
|
@ -116,7 +114,7 @@ sealed interface ArtistShowChoices {
|
||||||
override val uid = album.uid
|
override val uid = album.uid
|
||||||
override val choices = album.artists
|
override val choices = album.artists
|
||||||
|
|
||||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
override fun sanitize(newLibrary: Library) =
|
||||||
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
|
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.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
|
||||||
|
|
@ -85,7 +85,7 @@ class ShowArtistDialog :
|
||||||
|
|
||||||
private fun updateChoices(choices: ArtistShowChoices?) {
|
private fun updateChoices(choices: ArtistShowChoices?) {
|
||||||
if (choices == null) {
|
if (choices == null) {
|
||||||
logD("No choices to show, navigating away")
|
L.d("No choices to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* AlbumDetailHeaderAdapter.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.header
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.getPlural
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [DetailHeaderAdapter] that shows [Album] information.
|
|
||||||
*
|
|
||||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class AlbumDetailHeaderAdapter(private val listener: Listener) :
|
|
||||||
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
AlbumDetailHeaderViewHolder.from(parent)
|
|
||||||
|
|
||||||
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
|
|
||||||
holder.bind(parent, listener)
|
|
||||||
|
|
||||||
/** An extended listener for [DetailHeaderAdapter] implementations. */
|
|
||||||
interface Listener : DetailHeaderAdapter.Listener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the artist name in the [Album] header was clicked, requesting navigation to
|
|
||||||
* it's parent artist.
|
|
||||||
*/
|
|
||||||
fun onNavigateToParentArtist()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
|
|
||||||
* create an instance.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class AlbumDetailHeaderViewHolder
|
|
||||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind new data to this instance.
|
|
||||||
*
|
|
||||||
* @param album The new [Album] to bind.
|
|
||||||
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
*/
|
|
||||||
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
|
|
||||||
binding.detailCover.bind(album)
|
|
||||||
|
|
||||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
|
||||||
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
|
|
||||||
|
|
||||||
binding.detailName.text = album.name.resolve(binding.context)
|
|
||||||
|
|
||||||
// Artist name maps to the subhead text
|
|
||||||
binding.detailSubhead.apply {
|
|
||||||
text = album.artists.resolveNames(context)
|
|
||||||
|
|
||||||
// Add a QoL behavior where navigation to the artist will occur if the artist
|
|
||||||
// name is pressed.
|
|
||||||
setOnClickListener { listener.onNavigateToParentArtist() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
|
||||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a new instance.
|
|
||||||
*
|
|
||||||
* @param parent The parent to inflate this instance from.
|
|
||||||
* @return A new instance.
|
|
||||||
*/
|
|
||||||
fun from(parent: View) =
|
|
||||||
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* ArtistDetailHeaderAdapter.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.header
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
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.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [DetailHeaderAdapter] that shows [Artist] information.
|
|
||||||
*
|
|
||||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class ArtistDetailHeaderAdapter(private val listener: Listener) :
|
|
||||||
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
ArtistDetailHeaderViewHolder.from(parent)
|
|
||||||
|
|
||||||
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
|
|
||||||
holder.bind(parent, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
|
|
||||||
* create an instance.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class ArtistDetailHeaderViewHolder
|
|
||||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind new data to this instance.
|
|
||||||
*
|
|
||||||
* @param artist The new [Artist] to bind.
|
|
||||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
*/
|
|
||||||
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
|
|
||||||
binding.detailCover.bind(artist)
|
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
|
|
||||||
binding.detailName.text = artist.name.resolve(binding.context)
|
|
||||||
|
|
||||||
// Song and album counts map to the info
|
|
||||||
binding.detailInfo.text =
|
|
||||||
binding.context.getString(
|
|
||||||
R.string.fmt_two,
|
|
||||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
|
||||||
if (artist.songs.isNotEmpty()) {
|
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
|
||||||
} else {
|
|
||||||
binding.context.getString(R.string.def_song_count)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (artist.songs.isNotEmpty()) {
|
|
||||||
// Information about the artist's genre(s) map to the sub-head text
|
|
||||||
binding.detailSubhead.apply {
|
|
||||||
isVisible = true
|
|
||||||
text = artist.genres.resolveNames(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In the case that this header used to he configured to have no songs,
|
|
||||||
// we want to reset the visibility of all information that was hidden.
|
|
||||||
binding.detailPlayButton.isVisible = true
|
|
||||||
binding.detailShuffleButton.isVisible = true
|
|
||||||
} else {
|
|
||||||
// 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")
|
|
||||||
binding.detailSubhead.isVisible = false
|
|
||||||
binding.detailPlayButton.isEnabled = false
|
|
||||||
binding.detailShuffleButton.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
|
||||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a new instance.
|
|
||||||
*
|
|
||||||
* @param parent The parent to inflate this instance from.
|
|
||||||
* @return A new instance.
|
|
||||||
*/
|
|
||||||
fun from(parent: View) =
|
|
||||||
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* DetailHeaderAdapter.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.header
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
|
|
||||||
RecyclerView.Adapter<VH>() {
|
|
||||||
private var currentParent: T? = null
|
|
||||||
|
|
||||||
final override fun getItemCount() = 1
|
|
||||||
|
|
||||||
final override fun onBindViewHolder(holder: VH, position: Int) =
|
|
||||||
onBindHeader(holder, requireNotNull(currentParent))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
|
|
||||||
*
|
|
||||||
* @param holder The [RecyclerView.ViewHolder] to bind.
|
|
||||||
* @param parent The current [MusicParent] to bind.
|
|
||||||
*/
|
|
||||||
abstract fun onBindHeader(holder: VH, parent: T)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the [MusicParent] shown in the header.
|
|
||||||
*
|
|
||||||
* @param parent The new [MusicParent] to show.
|
|
||||||
*/
|
|
||||||
fun setParent(parent: T) {
|
|
||||||
logD("Updating parent [old: $currentParent new: $parent]")
|
|
||||||
currentParent = parent
|
|
||||||
rebindParent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
|
|
||||||
*/
|
|
||||||
protected fun rebindParent() {
|
|
||||||
logD("Rebinding parent")
|
|
||||||
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A listener for [DetailHeaderAdapter] implementations. */
|
|
||||||
interface Listener {
|
|
||||||
/**
|
|
||||||
* Called when the play button in a detail header is pressed, requesting that the current
|
|
||||||
* item should be played.
|
|
||||||
*/
|
|
||||||
fun onPlay()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the shuffle button in a detail header is pressed, requesting that the current
|
|
||||||
* item should be shuffled
|
|
||||||
*/
|
|
||||||
fun onShuffle()
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val PAYLOAD_UPDATE_HEADER = Any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* GenreDetailHeaderAdapter.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.header
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.getPlural
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [DetailHeaderAdapter] that shows [Genre] information.
|
|
||||||
*
|
|
||||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class GenreDetailHeaderAdapter(private val listener: Listener) :
|
|
||||||
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
GenreDetailHeaderViewHolder.from(parent)
|
|
||||||
|
|
||||||
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
|
|
||||||
holder.bind(parent, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
|
|
||||||
* create an instance.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class GenreDetailHeaderViewHolder
|
|
||||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
/**
|
|
||||||
* Bind new data to this instance.
|
|
||||||
*
|
|
||||||
* @param genre The new [Genre] to bind.
|
|
||||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
*/
|
|
||||||
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
|
|
||||||
binding.detailCover.bind(genre)
|
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
|
||||||
binding.detailName.text = genre.name.resolve(binding.context)
|
|
||||||
// Nothing about a genre is applicable to the sub-head text.
|
|
||||||
binding.detailSubhead.isVisible = false
|
|
||||||
// The song and artist count of the genre maps to the info text.
|
|
||||||
binding.detailInfo.text =
|
|
||||||
binding.context.getString(
|
|
||||||
R.string.fmt_two,
|
|
||||||
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
|
||||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
|
||||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a new instance.
|
|
||||||
*
|
|
||||||
* @param parent The parent to inflate this instance from.
|
|
||||||
* @return A new instance.
|
|
||||||
*/
|
|
||||||
fun from(parent: View) =
|
|
||||||
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* PlaylistDetailHeaderAdapter.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.header
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.getPlural
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [DetailHeaderAdapter] that shows [Playlist] information.
|
|
||||||
*
|
|
||||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
|
||||||
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
|
|
||||||
private var editedPlaylist: List<Song>? = null
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
PlaylistDetailHeaderViewHolder.from(parent)
|
|
||||||
|
|
||||||
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
|
|
||||||
holder.bind(parent, editedPlaylist, listener)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicate to this adapter that editing is ongoing with the current state of the editing
|
|
||||||
* process. This will make the header immediately update to reflect information about the edited
|
|
||||||
* playlist.
|
|
||||||
*/
|
|
||||||
fun setEditedPlaylist(songs: List<Song>?) {
|
|
||||||
if (editedPlaylist == songs) {
|
|
||||||
// Nothing to do.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
|
|
||||||
editedPlaylist = songs
|
|
||||||
rebindParent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
|
|
||||||
* create an instance.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class PlaylistDetailHeaderViewHolder
|
|
||||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
|
||||||
/**
|
|
||||||
* Bind new data to this instance.
|
|
||||||
*
|
|
||||||
* @param playlist The new [Playlist] to bind.
|
|
||||||
* @param editedPlaylist The current edited state of the playlist, if it exists.
|
|
||||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
|
||||||
*/
|
|
||||||
fun bind(
|
|
||||||
playlist: Playlist,
|
|
||||||
editedPlaylist: List<Song>?,
|
|
||||||
listener: DetailHeaderAdapter.Listener
|
|
||||||
) {
|
|
||||||
if (editedPlaylist != null) {
|
|
||||||
logD("Binding edited playlist image")
|
|
||||||
binding.detailCover.bind(
|
|
||||||
editedPlaylist,
|
|
||||||
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
|
||||||
R.drawable.ic_playlist_24)
|
|
||||||
} else {
|
|
||||||
binding.detailCover.bind(playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
|
||||||
binding.detailName.text = playlist.name.resolve(binding.context)
|
|
||||||
// Nothing about a playlist is applicable to the sub-head text.
|
|
||||||
binding.detailSubhead.isVisible = false
|
|
||||||
|
|
||||||
val songs = editedPlaylist ?: playlist.songs
|
|
||||||
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
|
|
||||||
// The song count of the playlist maps to the info text.
|
|
||||||
binding.detailInfo.text =
|
|
||||||
if (songs.isNotEmpty()) {
|
|
||||||
binding.context.getString(
|
|
||||||
R.string.fmt_two,
|
|
||||||
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
|
|
||||||
durationMs.formatDurationMs(true))
|
|
||||||
} else {
|
|
||||||
binding.context.getString(R.string.def_song_count)
|
|
||||||
}
|
|
||||||
|
|
||||||
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
|
||||||
if (!playable) {
|
|
||||||
logD("Playlist is being edited or is empty, disabling playback options")
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.detailPlayButton.apply {
|
|
||||||
isEnabled = playable
|
|
||||||
setOnClickListener { listener.onPlay() }
|
|
||||||
}
|
|
||||||
binding.detailShuffleButton.apply {
|
|
||||||
isEnabled = playable
|
|
||||||
setOnClickListener { listener.onShuffle() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a new instance.
|
|
||||||
*
|
|
||||||
* @param parent The parent to inflate this instance from.
|
|
||||||
* @return A new instance.
|
|
||||||
*/
|
|
||||||
fun from(parent: View) =
|
|
||||||
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,21 +24,25 @@ import androidx.core.view.isGone
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.divider.MaterialDivider
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
|
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
|
||||||
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
|
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.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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)) {
|
when (getItem(position)) {
|
||||||
// Support sub-headers for each disc, and special album songs.
|
// Support sub-headers for each disc, and special album songs.
|
||||||
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
||||||
|
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
|
||||||
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
||||||
else -> super.getItemViewType(position)
|
else -> super.getItemViewType(position)
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +64,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||||
when (viewType) {
|
when (viewType) {
|
||||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||||
|
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
|
||||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||||
else -> super.onCreateViewHolder(parent, viewType)
|
else -> super.onCreateViewHolder(parent, viewType)
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +85,8 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
when {
|
when {
|
||||||
oldItem is Disc && newItem is Disc ->
|
oldItem is Disc && newItem is Disc ->
|
||||||
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
|
oldItem is DiscDivider && newItem is DiscDivider ->
|
||||||
|
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
oldItem is Song && newItem is Song ->
|
oldItem is Song && newItem is Song ->
|
||||||
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
|
|
||||||
|
|
@ -94,7 +102,9 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
* 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) {
|
fun bind(discHeader: DiscHeader) {
|
||||||
val disc = discHeader.inner
|
val disc = discHeader.inner
|
||||||
if (disc != null) {
|
binding.discNumber.text = disc.resolve(binding.context)
|
||||||
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
|
binding.discName.apply {
|
||||||
binding.discName.apply {
|
text = disc?.name
|
||||||
text = disc.name
|
isGone = disc?.name == null
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
|
||||||
* create an instance.
|
* create an instance.
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,13 @@ import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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.parentName.text = album.name.resolve(binding.context)
|
||||||
binding.parentInfo.text =
|
binding.parentInfo.text =
|
||||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||||
album.dates?.resolveDate(binding.context)
|
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
|
||||||
?: binding.context.getString(R.string.def_date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
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.IntegerTable
|
||||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||||
import org.oxycblt.auxio.list.BasicHeader
|
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.Item
|
||||||
|
import org.oxycblt.auxio.list.PlainDivider
|
||||||
|
import org.oxycblt.auxio.list.PlainHeader
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
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
|
* 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) =
|
override fun getItemViewType(position: Int) =
|
||||||
when (getItem(position)) {
|
when (getItem(position)) {
|
||||||
// Implement support for headers and sort headers
|
// Implement support for headers and sort headers
|
||||||
is Divider -> DividerViewHolder.VIEW_TYPE
|
is PlainDivider -> DividerViewHolder.VIEW_TYPE
|
||||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||||
else -> super.getItemViewType(position)
|
else -> super.getItemViewType(position)
|
||||||
|
|
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
|
||||||
object : SimpleDiffCallback<Item>() {
|
object : SimpleDiffCallback<Item>() {
|
||||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||||
return when {
|
return when {
|
||||||
oldItem is Divider && newItem is Divider ->
|
oldItem is PlainDivider && newItem is PlainDivider ->
|
||||||
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||||
|
|
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
|
||||||
* @param titleRes The string resource to use as the header title
|
* @param titleRes The string resource to use as the header title
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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
|
* 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.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.musikr.Artist
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.musikr.Genre
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.musikr.Music
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.musikr.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
|
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
|
||||||
|
|
|
||||||
|
|
@ -30,25 +30,24 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.R as MR
|
import com.google.android.material.R as MR
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
|
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
|
||||||
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
|
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
|
||||||
import org.oxycblt.auxio.list.EditableListListener
|
import org.oxycblt.auxio.list.EditableListListener
|
||||||
import org.oxycblt.auxio.list.Header
|
|
||||||
import org.oxycblt.auxio.list.Item
|
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.PlayingIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getDimen
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
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]
|
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
||||||
|
|
@ -99,9 +98,9 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
||||||
// Nothing to do.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Updating editing state [old: $isEditing new: $editing]")
|
L.d("Updating editing state [old: $isEditing new: $editing]")
|
||||||
this.isEditing = 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]. */
|
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
|
||||||
|
|
@ -142,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
|
* @param titleRes The string resource to use as the header title
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
|
||||||
|
|
@ -232,8 +231,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
||||||
override val delete = binding.background
|
override val delete = binding.background
|
||||||
override val background =
|
override val background =
|
||||||
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
||||||
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
|
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
|
||||||
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
|
||||||
alpha = 0
|
alpha = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,26 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.detail.list
|
package org.oxycblt.auxio.detail.list
|
||||||
|
|
||||||
|
import android.text.format.Formatter
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
|
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.FlexibleListAdapter
|
||||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
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.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* An adapter for [SongProperty] instances.
|
||||||
|
|
@ -53,7 +62,31 @@ class SongPropertyAdapter :
|
||||||
* @param value The value of the property.
|
* @param value The value of the property.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* 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) {
|
fun bind(property: SongProperty) {
|
||||||
val context = binding.context
|
val context = binding.context
|
||||||
binding.propertyName.hint = context.getString(property.name)
|
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 {
|
companion object {
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.list.sort.SortDialog
|
import org.oxycblt.auxio.list.sort.SortDialog
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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].
|
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
|
||||||
|
|
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
|
||||||
|
|
||||||
private fun updateAlbum(album: Album?) {
|
private fun updateAlbum(album: Album?) {
|
||||||
if (album == null) {
|
if (album == null) {
|
||||||
logD("No album to sort, navigating away")
|
L.d("No album to sort, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.list.sort.SortDialog
|
import org.oxycblt.auxio.list.sort.SortDialog
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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].
|
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
|
||||||
|
|
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
|
||||||
|
|
||||||
private fun updateArtist(artist: Artist?) {
|
private fun updateArtist(artist: Artist?) {
|
||||||
if (artist == null) {
|
if (artist == null) {
|
||||||
logD("No artist to sort, navigating away")
|
L.d("No artist to sort, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.list.sort.SortDialog
|
import org.oxycblt.auxio.list.sort.SortDialog
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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].
|
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||||
|
|
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
|
||||||
|
|
||||||
private fun updateGenre(genre: Genre?) {
|
private fun updateGenre(genre: Genre?) {
|
||||||
if (genre == null) {
|
if (genre == null) {
|
||||||
logD("No genre to sort, navigating away")
|
L.d("No genre to sort, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.list.sort.SortDialog
|
import org.oxycblt.auxio.list.sort.SortDialog
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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].
|
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||||
|
|
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
|
||||||
|
|
||||||
private fun updatePlaylist(genre: Playlist?) {
|
private fun updatePlaylist(genre: Playlist?) {
|
||||||
if (genre == null) {
|
if (genre == null) {
|
||||||
logD("No genre to sort, navigating away")
|
L.d("No genre to sort, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
||||||
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||||
import org.oxycblt.auxio.util.openInBrowser
|
import org.oxycblt.auxio.util.openInBrowser
|
||||||
|
|
@ -42,10 +44,12 @@ import org.oxycblt.auxio.util.showToast
|
||||||
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
||||||
private val args: ErrorDetailsDialogArgs by navArgs()
|
private val args: ErrorDetailsDialogArgs by navArgs()
|
||||||
private var clipboardManager: ClipboardManager? = null
|
private var clipboardManager: ClipboardManager? = null
|
||||||
|
private val musicModel: MusicViewModel by viewModels()
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.lbl_error_info)
|
.setTitle(R.string.lbl_error_info)
|
||||||
|
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
|
||||||
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
||||||
requireContext().openInBrowser(LINK_ISSUES)
|
requireContext().openInBrowser(LINK_ISSUES)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* FlipFloatingActionButton.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 android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.google.android.material.R
|
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An extension of [FloatingActionButton] that enables the ability to fade in and out between
|
|
||||||
* several states, as in the Material Design 3 specification.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class FlipFloatingActionButton
|
|
||||||
@JvmOverloads
|
|
||||||
constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
defStyleAttr: Int = R.attr.floatingActionButtonStyle
|
|
||||||
) : FloatingActionButton(context, attrs, defStyleAttr) {
|
|
||||||
private var pendingConfig: PendingConfig? = null
|
|
||||||
private var flipping = false
|
|
||||||
|
|
||||||
override fun show() {
|
|
||||||
// Will already show eventually, need to do nothing.
|
|
||||||
if (flipping) {
|
|
||||||
logD("Already flipping, aborting show")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Apply the new configuration possibly set in flipTo. This should occur even if
|
|
||||||
// a flip was canceled by a hide.
|
|
||||||
pendingConfig?.run {
|
|
||||||
this@FlipFloatingActionButton.logD("Applying pending configuration")
|
|
||||||
setImageResource(iconRes)
|
|
||||||
contentDescription = context.getString(contentDescriptionRes)
|
|
||||||
setOnClickListener(clickListener)
|
|
||||||
}
|
|
||||||
pendingConfig = null
|
|
||||||
logD("Beginning show")
|
|
||||||
super.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hide() {
|
|
||||||
if (flipping) {
|
|
||||||
logD("Hide was called, aborting flip")
|
|
||||||
}
|
|
||||||
// Not flipping anymore, disable the flag so that the FAB is not re-shown.
|
|
||||||
flipping = false
|
|
||||||
// Don't pass any kind of listener so that future flip operations will not be able
|
|
||||||
// to show the FAB again.
|
|
||||||
logD("Beginning hide")
|
|
||||||
super.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip to a new FAB state.
|
|
||||||
*
|
|
||||||
* @param iconRes The resource of the new FAB icon.
|
|
||||||
* @param contentDescriptionRes The resource of the new FAB content description.
|
|
||||||
*/
|
|
||||||
fun flipTo(
|
|
||||||
@DrawableRes iconRes: Int,
|
|
||||||
@StringRes contentDescriptionRes: Int,
|
|
||||||
clickListener: OnClickListener
|
|
||||||
) {
|
|
||||||
// Avoid doing a flip if the given config is already being applied.
|
|
||||||
if (tag == iconRes) return
|
|
||||||
tag = iconRes
|
|
||||||
pendingConfig = PendingConfig(iconRes, contentDescriptionRes, clickListener)
|
|
||||||
|
|
||||||
// Already hiding for whatever reason, apply the configuration when the FAB is shown again.
|
|
||||||
if (!isOrWillBeHidden) {
|
|
||||||
logD("Starting hide for flip")
|
|
||||||
flipping = true
|
|
||||||
// We will re-show the FAB later, assuming that there was not a prior flip operation.
|
|
||||||
super.hide(FlipVisibilityListener())
|
|
||||||
} else {
|
|
||||||
logD("Already hiding, will apply config later")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PendingConfig(
|
|
||||||
@DrawableRes val iconRes: Int,
|
|
||||||
@StringRes val contentDescriptionRes: Int,
|
|
||||||
val clickListener: OnClickListener
|
|
||||||
)
|
|
||||||
|
|
||||||
private inner class FlipVisibilityListener : OnVisibilityChangedListener() {
|
|
||||||
override fun onHidden(fab: FloatingActionButton) {
|
|
||||||
if (!flipping) return
|
|
||||||
logD("Starting show for flip")
|
|
||||||
flipping = false
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,13 +18,14 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.home
|
package org.oxycblt.auxio.home
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.view.MenuCompat
|
import androidx.core.view.MenuCompat
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
|
@ -41,7 +42,6 @@ import com.google.android.material.transition.MaterialSharedAxis
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import org.oxycblt.auxio.BuildConfig
|
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
|
|
@ -51,29 +51,28 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
|
||||||
import org.oxycblt.auxio.home.list.GenreListFragment
|
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||||
import org.oxycblt.auxio.home.list.PlaylistListFragment
|
import org.oxycblt.auxio.home.list.PlaylistListFragment
|
||||||
import org.oxycblt.auxio.home.list.SongListFragment
|
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.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectionFragment
|
import org.oxycblt.auxio.list.SelectionFragment
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
import org.oxycblt.auxio.list.menu.Menu
|
||||||
import org.oxycblt.auxio.music.IndexingProgress
|
|
||||||
import org.oxycblt.auxio.music.IndexingState
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
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.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.getColorCompat
|
|
||||||
import org.oxycblt.auxio.util.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
import org.oxycblt.auxio.util.logW
|
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
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
|
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||||
|
|
@ -90,25 +89,23 @@ class HomeFragment :
|
||||||
private val homeModel: HomeViewModel by activityViewModels()
|
private val homeModel: HomeViewModel by activityViewModels()
|
||||||
private val detailModel: DetailViewModel by activityViewModels()
|
private val detailModel: DetailViewModel by activityViewModels()
|
||||||
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
private var getContentLauncher: ActivityResultLauncher<String>? = null
|
||||||
|
private var pendingImportTarget: Playlist? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
if (savedInstanceState != null) {
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
// Orientation change will wipe whatever transition we were using prior, which will
|
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
// result in no transition when the user navigates back. Make sure we re-initialize
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||||
// our transitions.
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||||
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1)
|
|
||||||
if (axis > -1) {
|
|
||||||
applyAxisTransition(axis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
|
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
|
||||||
|
|
||||||
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
|
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||||
super.onBindingCreated(binding, savedInstanceState)
|
super.onBindingCreated(binding, savedInstanceState)
|
||||||
|
|
||||||
|
|
@ -118,18 +115,25 @@ class HomeFragment :
|
||||||
musicModel.refresh()
|
musicModel.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getContentLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
if (uri == null) {
|
||||||
|
L.w("No URI returned from file picker")
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
L.d("Received playlist URI $uri")
|
||||||
|
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||||
|
}
|
||||||
|
|
||||||
// --- UI SETUP ---
|
// --- UI SETUP ---
|
||||||
|
|
||||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||||
binding.homeNormalToolbar.apply {
|
binding.homeNormalToolbar.apply {
|
||||||
setOnMenuItemClickListener(this@HomeFragment)
|
setOnMenuItemClickListener(this@HomeFragment)
|
||||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
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 {
|
binding.homePager.apply {
|
||||||
// Update HomeViewModel whenever the user swipes through the ViewPager.
|
// Update HomeViewModel whenever the user swipes through the ViewPager.
|
||||||
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
|
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
|
||||||
|
|
@ -168,22 +172,15 @@ class HomeFragment :
|
||||||
|
|
||||||
// --- VIEWMODEL SETUP ---
|
// --- VIEWMODEL SETUP ---
|
||||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||||
|
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
|
||||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||||
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collectImmediately(musicModel.playlistMessage.flow, ::handlePlaylistMessage)
|
||||||
}
|
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
val transition = enterTransition
|
|
||||||
if (transition is MaterialSharedAxis) {
|
|
||||||
outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis)
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
||||||
|
|
@ -212,18 +209,17 @@ class HomeFragment :
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
// Handle main actions (Search, Settings, About)
|
// Handle main actions (Search, Settings, About)
|
||||||
R.id.action_search -> {
|
R.id.action_search -> {
|
||||||
logD("Navigating to search")
|
L.d("Navigating to search")
|
||||||
applyAxisTransition(MaterialSharedAxis.Z)
|
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.search())
|
findNavController().navigateSafe(HomeFragmentDirections.search())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_settings -> {
|
R.id.action_settings -> {
|
||||||
logD("Navigating to preferences")
|
L.d("Navigating to preferences")
|
||||||
homeModel.showSettings()
|
homeModel.showSettings()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_about -> {
|
R.id.action_about -> {
|
||||||
logD("Navigating to about")
|
L.d("Navigating to about")
|
||||||
homeModel.showAbout()
|
homeModel.showAbout()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +239,7 @@ class HomeFragment :
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
logW("Unexpected menu item selected")
|
L.w("Unexpected menu item selected")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +253,7 @@ class HomeFragment :
|
||||||
if (homeModel.currentTabTypes.size == 1) {
|
if (homeModel.currentTabTypes.size == 1) {
|
||||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||||
// behavior.
|
// behavior.
|
||||||
logD("Single tab shown, disabling TabLayout")
|
L.d("Single tab shown, disabling TabLayout")
|
||||||
binding.homeTabs.isVisible = false
|
binding.homeTabs.isVisible = false
|
||||||
binding.homeAppbar.setExpanded(true, false)
|
binding.homeAppbar.setExpanded(true, false)
|
||||||
toolbarParams.scrollFlags = 0
|
toolbarParams.scrollFlags = 0
|
||||||
|
|
@ -270,9 +266,7 @@ class HomeFragment :
|
||||||
|
|
||||||
// Set up the mapping between the ViewPager and TabLayout.
|
// Set up the mapping between the ViewPager and TabLayout.
|
||||||
TabLayoutMediator(
|
TabLayoutMediator(
|
||||||
binding.homeTabs,
|
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
|
||||||
binding.homePager,
|
|
||||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
|
|
||||||
.attach()
|
.attach()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,24 +284,12 @@ class HomeFragment :
|
||||||
MusicType.GENRES -> R.id.home_genre_recycler
|
MusicType.GENRES -> R.id.home_genre_recycler
|
||||||
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
|
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabType != MusicType.PLAYLISTS) {
|
|
||||||
logD("Flipping to shuffle button")
|
|
||||||
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
|
||||||
playbackModel.shuffleAll()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logD("Flipping to playlist button")
|
|
||||||
binding.homeFab.flipTo(R.drawable.ic_add_24, R.string.desc_new_playlist) {
|
|
||||||
musicModel.createPlaylist()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRecreate(recreate: Unit?) {
|
private fun handleRecreate(recreate: Unit?) {
|
||||||
if (recreate == null) return
|
if (recreate == null) return
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
logD("Recreating ViewPager")
|
L.d("Recreating ViewPager")
|
||||||
// Move back to position zero, as there must be a tab there.
|
// Move back to position zero, as there must be a tab there.
|
||||||
binding.homePager.currentItem = 0
|
binding.homePager.currentItem = 0
|
||||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||||
|
|
@ -315,123 +297,92 @@ class HomeFragment :
|
||||||
homeModel.recreateTabs.consume()
|
homeModel.recreateTabs.consume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIndexerState(state: IndexingState?) {
|
private fun handleChooseFolders(unit: Unit?) {
|
||||||
// TODO: Make music loading experience a bit more pleasant
|
if (unit == null) {
|
||||||
// 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.homeFab.show()
|
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
|
||||||
logD("Received non-ok response")
|
homeModel.chooseMusicLocations.consume()
|
||||||
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.text = context.getString(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.text = context.getString(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.text = context.getString(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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
private fun updateIndexerState(state: IndexingState?) {
|
||||||
// Remove all content except for the progress indicator.
|
val binding = requireBinding()
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
when (state) {
|
||||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
is IndexingState.Completed -> {
|
||||||
binding.homeIndexingActions.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.isInvisible = state.error == null
|
||||||
|
binding.homeIndexingProgress.isInvisible = state.error != null
|
||||||
when (progress) {
|
binding.homeIndexingError.isInvisible = state.error == null
|
||||||
is IndexingProgress.Indeterminate -> {
|
if (state.error != null) {
|
||||||
// In a query/initialization state, show a generic loading status.
|
binding.homeIndexingContainer.setOnClickListener {
|
||||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
findNavController()
|
||||||
binding.homeIndexingProgress.isIndeterminate = true
|
.navigateSafe(HomeFragmentDirections.reportError(state.error))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.homeIndexingContainer.setOnClickListener(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is IndexingProgress.Songs -> {
|
is IndexingState.Indexing -> {
|
||||||
// Actively loading songs, show the current progress.
|
binding.homeIndexingContainer.isInvisible = false
|
||||||
binding.homeIndexingStatus.text =
|
|
||||||
getString(R.string.fmt_indexing, progress.current, progress.total)
|
|
||||||
binding.homeIndexingProgress.apply {
|
binding.homeIndexingProgress.apply {
|
||||||
isIndeterminate = false
|
isInvisible = false
|
||||||
max = progress.total
|
when (state.progress) {
|
||||||
this.progress = progress.current
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDecision(decision: PlaylistDecision?) {
|
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||||
if (decision == null) return
|
if (decision == null) return
|
||||||
val directions =
|
val directions =
|
||||||
when (decision) {
|
when (decision) {
|
||||||
is PlaylistDecision.New -> {
|
is PlaylistDecision.New -> {
|
||||||
logD("Creating new playlist")
|
L.d("Creating new playlist")
|
||||||
HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())
|
HomeFragmentDirections.newPlaylist(
|
||||||
|
decision.songs.map { it.uid }.toTypedArray(),
|
||||||
|
decision.template,
|
||||||
|
decision.reason)
|
||||||
|
}
|
||||||
|
is PlaylistDecision.Import -> {
|
||||||
|
L.d("Importing playlist")
|
||||||
|
pendingImportTarget = decision.target
|
||||||
|
requireNotNull(getContentLauncher) {
|
||||||
|
"Content picker launcher was not available"
|
||||||
|
}
|
||||||
|
.launch(M3U.MIME_TYPE)
|
||||||
|
musicModel.playlistDecision.consume()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
is PlaylistDecision.Rename -> {
|
is PlaylistDecision.Rename -> {
|
||||||
logD("Renaming ${decision.playlist}")
|
L.d("Renaming ${decision.playlist}")
|
||||||
HomeFragmentDirections.renamePlaylist(decision.playlist.uid)
|
HomeFragmentDirections.renamePlaylist(
|
||||||
|
decision.playlist.uid,
|
||||||
|
decision.template,
|
||||||
|
decision.applySongs.map { it.uid }.toTypedArray(),
|
||||||
|
decision.reason)
|
||||||
|
}
|
||||||
|
is PlaylistDecision.Export -> {
|
||||||
|
L.d("Exporting ${decision.playlist}")
|
||||||
|
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||||
}
|
}
|
||||||
is PlaylistDecision.Delete -> {
|
is PlaylistDecision.Delete -> {
|
||||||
logD("Deleting ${decision.playlist}")
|
L.d("Deleting ${decision.playlist}")
|
||||||
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
|
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||||
}
|
}
|
||||||
is PlaylistDecision.Add -> {
|
is PlaylistDecision.Add -> {
|
||||||
logD("Adding ${decision.songs.size} to a playlist")
|
L.d("Adding ${decision.songs.size} to a playlist")
|
||||||
HomeFragmentDirections.addToPlaylist(
|
HomeFragmentDirections.addToPlaylist(
|
||||||
decision.songs.map { it.uid }.toTypedArray())
|
decision.songs.map { it.uid }.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
@ -439,60 +390,61 @@ class HomeFragment :
|
||||||
findNavController().navigateSafe(directions)
|
findNavController().navigateSafe(directions)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
private fun handlePlaylistMessage(message: PlaylistMessage?) {
|
||||||
val binding = requireBinding()
|
if (message == null) return
|
||||||
// If there are no songs, it's likely that the library has not been loaded, so
|
requireContext().showToast(message.stringRes)
|
||||||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
musicModel.playlistMessage.consume()
|
||||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
}
|
||||||
if (songs.isEmpty() || isFastScrolling) {
|
|
||||||
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||||
binding.homeFab.hide()
|
when (decision) {
|
||||||
} else {
|
is PlaybackDecision.PlayFromArtist -> {
|
||||||
logD("Showing fab")
|
findNavController()
|
||||||
binding.homeFab.show()
|
.navigateSafe(HomeFragmentDirections.playFromArtist(decision.song.uid))
|
||||||
|
}
|
||||||
|
is PlaybackDecision.PlayFromGenre -> {
|
||||||
|
findNavController()
|
||||||
|
.navigateSafe(HomeFragmentDirections.playFromGenre(decision.song.uid))
|
||||||
|
}
|
||||||
|
null -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleShow(show: Show?) {
|
private fun handleShow(show: Show?) {
|
||||||
when (show) {
|
when (show) {
|
||||||
is Show.SongDetails -> {
|
is Show.SongDetails -> {
|
||||||
logD("Navigating to ${show.song}")
|
L.d("Navigating to ${show.song}")
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
|
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.SongAlbumDetails -> {
|
is Show.SongAlbumDetails -> {
|
||||||
logD("Navigating to the album of ${show.song}")
|
L.d("Navigating to the album of ${show.song}")
|
||||||
applyAxisTransition(MaterialSharedAxis.X)
|
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
|
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumDetails -> {
|
is Show.AlbumDetails -> {
|
||||||
logD("Navigating to ${show.album}")
|
L.d("Navigating to ${show.album}")
|
||||||
applyAxisTransition(MaterialSharedAxis.X)
|
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
|
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
|
||||||
}
|
}
|
||||||
is Show.ArtistDetails -> {
|
is Show.ArtistDetails -> {
|
||||||
logD("Navigating to ${show.artist}")
|
L.d("Navigating to ${show.artist}")
|
||||||
applyAxisTransition(MaterialSharedAxis.X)
|
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
|
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
|
||||||
}
|
}
|
||||||
is Show.SongArtistDecision -> {
|
is Show.SongArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.song}")
|
L.d("Navigating to artist choices for ${show.song}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
|
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
|
||||||
}
|
}
|
||||||
is Show.AlbumArtistDecision -> {
|
is Show.AlbumArtistDecision -> {
|
||||||
logD("Navigating to artist choices for ${show.album}")
|
L.d("Navigating to artist choices for ${show.album}")
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
|
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
|
||||||
}
|
}
|
||||||
is Show.GenreDetails -> {
|
is Show.GenreDetails -> {
|
||||||
logD("Navigating to ${show.genre}")
|
L.d("Navigating to ${show.genre}")
|
||||||
applyAxisTransition(MaterialSharedAxis.X)
|
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
|
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
|
||||||
}
|
}
|
||||||
is Show.PlaylistDetails -> {
|
is Show.PlaylistDetails -> {
|
||||||
logD("Navigating to ${show.playlist}")
|
L.d("Navigating to ${show.playlist}")
|
||||||
applyAxisTransition(MaterialSharedAxis.X)
|
|
||||||
findNavController()
|
findNavController()
|
||||||
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
|
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
|
||||||
}
|
}
|
||||||
|
|
@ -520,7 +472,7 @@ class HomeFragment :
|
||||||
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||||
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
||||||
// New selection started, show the AppBarLayout to indicate the new state.
|
// 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()
|
binding.homeAppbar.expandWithScrollingRecycler()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -528,18 +480,6 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyAxisTransition(axis: Int) {
|
|
||||||
// Sanity check to avoid in-correct axis transitions
|
|
||||||
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
|
|
||||||
"Not expecting Y axis transition"
|
|
||||||
}
|
|
||||||
|
|
||||||
enterTransition = MaterialSharedAxis(axis, true)
|
|
||||||
returnTransition = MaterialSharedAxis(axis, false)
|
|
||||||
exitTransition = MaterialSharedAxis(axis, true)
|
|
||||||
reenterTransition = MaterialSharedAxis(axis, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
||||||
*
|
*
|
||||||
|
|
@ -568,6 +508,5 @@ class HomeFragment :
|
||||||
private companion object {
|
private companion object {
|
||||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||||
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||||
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface HomeModule {
|
interface HomeModule {
|
||||||
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
|
@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.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User configuration specific to the home UI.
|
* User configuration specific to the home UI.
|
||||||
|
|
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/** Called when the [homeTabs] configuration changes. */
|
/** Called when the [homeTabs] configuration changes. */
|
||||||
fun onTabsChanged()
|
fun onTabsChanged() {}
|
||||||
/** Called when the [shouldHideCollaborators] configuration changes. */
|
/** Called when the [shouldHideCollaborators] configuration changes. */
|
||||||
fun onHideCollaboratorsChanged()
|
fun onHideCollaboratorsChanged() {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
||||||
|
|
||||||
override fun migrate() {
|
override fun migrate() {
|
||||||
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
||||||
logD("Migrating tab setting")
|
L.d("Migrating tab setting")
|
||||||
val oldTabs =
|
val oldTabs =
|
||||||
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||||
?: unlikelyToBeNull(Tab.fromIntCode(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.
|
// The playlist tab is now parsed, but it needs to be made visible.
|
||||||
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
|
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
|
||||||
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||||
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
|
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
|
||||||
logD("New tabs: $oldTabs")
|
L.d("New tabs: $oldTabs")
|
||||||
|
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
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) {
|
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_home_tabs) -> {
|
getString(R.string.set_key_home_tabs) -> {
|
||||||
logD("Dispatching tab setting change")
|
L.d("Dispatching tab setting change")
|
||||||
listener.onTabsChanged()
|
listener.onTabsChanged()
|
||||||
}
|
}
|
||||||
getString(R.string.set_key_hide_collaborators) -> {
|
getString(R.string.set_key_hide_collaborators) -> {
|
||||||
logD("Dispatching collaborator setting change")
|
L.d("Dispatching collaborator setting change")
|
||||||
listener.onHideCollaboratorsChanged()
|
listener.onHideCollaboratorsChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,17 @@ import org.oxycblt.auxio.home.tabs.Tab
|
||||||
import org.oxycblt.auxio.list.ListSettings
|
import org.oxycblt.auxio.list.ListSettings
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
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.MusicType
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.playback.PlaySong
|
import org.oxycblt.auxio.playback.PlaySong
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
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.
|
* 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
|
class HomeViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val homeSettings: HomeSettings,
|
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val musicRepository: MusicRepository,
|
homeGeneratorFactory: HomeGenerator.Factory
|
||||||
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
) : ViewModel(), HomeGenerator.Invalidator {
|
||||||
|
|
||||||
private val _songList = MutableStateFlow(listOf<Song>())
|
private val _songList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||||
val songList: StateFlow<List<Song>>
|
val songList: StateFlow<List<Song>>
|
||||||
|
|
@ -123,6 +120,10 @@ constructor(
|
||||||
val playlistList: StateFlow<List<Playlist>>
|
val playlistList: StateFlow<List<Playlist>>
|
||||||
get() = _playlistList
|
get() = _playlistList
|
||||||
|
|
||||||
|
private val _empty = MutableStateFlow(false)
|
||||||
|
val empty: StateFlow<Boolean>
|
||||||
|
get() = _empty
|
||||||
|
|
||||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||||
/** Instructions for how to update [genreList] in the UI. */
|
/** Instructions for how to update [genreList] in the UI. */
|
||||||
val playlistInstructions: Event<UpdateInstructions>
|
val playlistInstructions: Event<UpdateInstructions>
|
||||||
|
|
@ -132,11 +133,13 @@ constructor(
|
||||||
val playlistSort: Sort
|
val playlistSort: Sort
|
||||||
get() = listSettings.playlistSort
|
get() = listSettings.playlistSort
|
||||||
|
|
||||||
|
private val homeGenerator = homeGeneratorFactory.create(this)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
||||||
* [Tab]s.
|
* [Tab]s.
|
||||||
*/
|
*/
|
||||||
var currentTabTypes = makeTabTypes()
|
var currentTabTypes = homeGenerator.tabs()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
||||||
|
|
@ -160,64 +163,53 @@ constructor(
|
||||||
val showOuter: Event<Outer>
|
val showOuter: Event<Outer>
|
||||||
get() = _showOuter
|
get() = _showOuter
|
||||||
|
|
||||||
|
private val _chooseMusicLocations = MutableEvent<Unit>()
|
||||||
|
val chooseMusicLocations: Event<Unit>
|
||||||
|
get() = _chooseMusicLocations
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addUpdateListener(this)
|
homeGenerator.attach()
|
||||||
homeSettings.registerListener(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicRepository.removeUpdateListener(this)
|
homeGenerator.release()
|
||||||
homeSettings.unregisterListener(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun invalidateEmpty() {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
_empty.value = homeGenerator.empty()
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
val userLibrary = musicRepository.userLibrary
|
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||||
if (changes.userLibrary && userLibrary != null) {
|
when (type) {
|
||||||
logD("Refreshing playlists")
|
MusicType.SONGS -> {
|
||||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
_songInstructions.put(instructions)
|
||||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
_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() {
|
override fun invalidateTabs() {
|
||||||
// Tabs changed, update the current tabs and set up a re-create event.
|
currentTabTypes = homeGenerator.tabs()
|
||||||
currentTabTypes = makeTabTypes()
|
|
||||||
logD("Updating tabs: ${currentTabType.value}")
|
|
||||||
_shouldRecreate.put(Unit)
|
_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].
|
* Apply a new [Sort] to [songList].
|
||||||
*
|
*
|
||||||
|
|
@ -225,8 +217,6 @@ constructor(
|
||||||
*/
|
*/
|
||||||
fun applySongSort(sort: Sort) {
|
fun applySongSort(sort: Sort) {
|
||||||
listSettings.songSort = 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) {
|
fun applyAlbumSort(sort: Sort) {
|
||||||
listSettings.albumSort = 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) {
|
fun applyArtistSort(sort: Sort) {
|
||||||
listSettings.artistSort = 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) {
|
fun applyGenreSort(sort: Sort) {
|
||||||
listSettings.genreSort = 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) {
|
fun applyPlaylistSort(sort: Sort) {
|
||||||
listSettings.playlistSort = 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.
|
* @param pagerPos The new position of the ViewPager2 instance.
|
||||||
*/
|
*/
|
||||||
fun synchronizeTabPosition(pagerPos: Int) {
|
fun synchronizeTabPosition(pagerPos: Int) {
|
||||||
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
|
L.d("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||||
_currentTabType.value = currentTabTypes[pagerPos]
|
_currentTabType.value = currentTabTypes[pagerPos]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,10 +271,14 @@ constructor(
|
||||||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun setFastScrolling(isFastScrolling: Boolean) {
|
fun setFastScrolling(isFastScrolling: Boolean) {
|
||||||
logD("Updating fast scrolling state: $isFastScrolling")
|
L.d("Updating fast scrolling state: $isFastScrolling")
|
||||||
_isFastScrolling.value = isFastScrolling
|
_isFastScrolling.value = isFastScrolling
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startChooseMusicLocations() {
|
||||||
|
_chooseMusicLocations.put(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
fun showSettings() {
|
fun showSettings() {
|
||||||
_showOuter.put(Outer.Settings)
|
_showOuter.put(Outer.Settings)
|
||||||
}
|
}
|
||||||
|
|
@ -300,15 +286,6 @@ constructor(
|
||||||
fun showAbout() {
|
fun showAbout() {
|
||||||
_showOuter.put(Outer.About)
|
_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 {
|
sealed interface Outer {
|
||||||
|
|
|
||||||
318
app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt
Normal file
318
app/src/main/java/org/oxycblt/auxio/home/ThemedSpeedDialView.kt
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2018 Auxio Project
|
||||||
|
* ThemedSpeedDialView.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 android.animation.Animator
|
||||||
|
import android.animation.AnimatorListenerAdapter
|
||||||
|
import android.animation.AnimatorSet
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.graphics.drawable.RotateDrawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Property
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import androidx.core.view.setMargins
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.core.widget.TextViewCompat
|
||||||
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import com.leinardi.android.speeddial.FabWithLabelView
|
||||||
|
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customized Speed Dial view with some bug fixes and Material 3 theming.
|
||||||
|
*
|
||||||
|
* Adapted from Material Files:
|
||||||
|
* https://github.com/zhanghai/MaterialFiles/tree/79f1727cec72a6a089eb495f79193f87459fc5e3
|
||||||
|
*
|
||||||
|
* MODIFICATIONS:
|
||||||
|
* - Removed dynamic theme changes based on the MaterialFile's Material 3 setting
|
||||||
|
* - Adapted code to the extensions in this project
|
||||||
|
*
|
||||||
|
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class ThemedSpeedDialView : SpeedDialView {
|
||||||
|
private var mainFabAnimator: Animator? = null
|
||||||
|
private val spacingSmall = context.getDimenPixels(R.dimen.spacing_small)
|
||||||
|
private var innerChangeListener: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
constructor(context: Context) : super(context)
|
||||||
|
|
||||||
|
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet?,
|
||||||
|
@AttrRes defStyleAttr: Int
|
||||||
|
) : super(context, attrs, defStyleAttr)
|
||||||
|
|
||||||
|
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Work around ripple bug on Android 12 when useCompatPadding = true.
|
||||||
|
// @see https://github.com/material-components/material-components-android/issues/2617
|
||||||
|
mainFab.apply {
|
||||||
|
updateLayoutParams<MarginLayoutParams> {
|
||||||
|
setMargins(context.getDimenPixels(R.dimen.spacing_medium))
|
||||||
|
}
|
||||||
|
useCompatPadding = false
|
||||||
|
}
|
||||||
|
val context = context
|
||||||
|
mainFabClosedBackgroundColor =
|
||||||
|
context
|
||||||
|
.getAttrColorCompat(com.google.android.material.R.attr.colorPrimaryContainer)
|
||||||
|
.defaultColor
|
||||||
|
mainFabClosedIconColor =
|
||||||
|
context
|
||||||
|
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimaryContainer)
|
||||||
|
.defaultColor
|
||||||
|
mainFabOpenedBackgroundColor =
|
||||||
|
context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary).defaultColor
|
||||||
|
mainFabOpenedIconColor =
|
||||||
|
context
|
||||||
|
.getAttrColorCompat(com.google.android.material.R.attr.colorOnPrimary)
|
||||||
|
.defaultColor
|
||||||
|
|
||||||
|
// Always use our own animation to fix the library issue that ripple is rotated as well.
|
||||||
|
val mainFabDrawable =
|
||||||
|
RotateDrawable().apply {
|
||||||
|
drawable = mainFab.drawable
|
||||||
|
toDegrees = 45f + 90f
|
||||||
|
}
|
||||||
|
mainFabAnimationRotateAngle = 0f
|
||||||
|
setMainFabClosedDrawable(mainFabDrawable)
|
||||||
|
setOnChangeListener(
|
||||||
|
object : OnChangeListener {
|
||||||
|
override fun onMainActionSelected(): Boolean = false
|
||||||
|
|
||||||
|
override fun onToggleChanged(isOpen: Boolean) {
|
||||||
|
mainFab.backgroundTintList =
|
||||||
|
ColorStateList.valueOf(
|
||||||
|
if (isOpen) mainFabClosedBackgroundColor
|
||||||
|
else mainFabOpenedBackgroundColor)
|
||||||
|
mainFab.imageTintList =
|
||||||
|
ColorStateList.valueOf(
|
||||||
|
if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor)
|
||||||
|
mainFabAnimator?.cancel()
|
||||||
|
mainFabAnimator =
|
||||||
|
createMainFabAnimator(isOpen).apply {
|
||||||
|
addListener(
|
||||||
|
object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
mainFabAnimator = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
innerChangeListener?.invoke(isOpen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMainFabAnimator(isOpen: Boolean): Animator {
|
||||||
|
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
|
||||||
|
|
||||||
|
val backgroundTintAnimator =
|
||||||
|
ObjectAnimator.ofArgb(
|
||||||
|
mainFab,
|
||||||
|
VIEW_PROPERTY_BACKGROUND_TINT,
|
||||||
|
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor)
|
||||||
|
.apply {
|
||||||
|
startDelay = delay
|
||||||
|
duration = partialDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
val imageTintAnimator =
|
||||||
|
ObjectAnimator.ofArgb(
|
||||||
|
mainFab,
|
||||||
|
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
|
||||||
|
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor)
|
||||||
|
.apply {
|
||||||
|
startDelay = delay
|
||||||
|
duration = partialDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
val levelAnimator =
|
||||||
|
ObjectAnimator.ofInt(
|
||||||
|
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0)
|
||||||
|
.apply { duration = totalDuration }
|
||||||
|
|
||||||
|
val animatorSet =
|
||||||
|
AnimatorSet().apply {
|
||||||
|
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
|
||||||
|
interpolator = stationaryConfig.interpolator
|
||||||
|
}
|
||||||
|
animatorSet.start()
|
||||||
|
return animatorSet
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
|
||||||
|
val overlayLayout = overlayLayout
|
||||||
|
if (overlayLayout != null) {
|
||||||
|
val surfaceColor =
|
||||||
|
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||||
|
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(
|
||||||
|
@FloatRange(from = 0.0, to = 1.0) alphaModulation: Float
|
||||||
|
): Int {
|
||||||
|
val alpha = (alpha * alphaModulation).roundToInt()
|
||||||
|
return ((alpha shl 24) or (this and 0x00FFFFFF))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addActionItem(
|
||||||
|
actionItem: SpeedDialActionItem,
|
||||||
|
position: Int,
|
||||||
|
animate: Boolean
|
||||||
|
): FabWithLabelView? {
|
||||||
|
val context = context
|
||||||
|
val fabImageTintColor = context.getAttrColorCompat(androidx.appcompat.R.attr.colorPrimary)
|
||||||
|
val fabBackgroundColor =
|
||||||
|
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||||
|
val labelColor = context.getAttrColorCompat(android.R.attr.textColorSecondary)
|
||||||
|
val labelBackgroundColor =
|
||||||
|
context.getAttrColorCompat(com.google.android.material.R.attr.colorSurface)
|
||||||
|
val labelElevation =
|
||||||
|
context.getDimen(com.google.android.material.R.dimen.m3_card_elevated_elevation)
|
||||||
|
val cornerRadius = context.getDimenPixels(R.dimen.spacing_medium)
|
||||||
|
val actionItem =
|
||||||
|
SpeedDialActionItem.Builder(
|
||||||
|
actionItem.id,
|
||||||
|
// Should not be a resource, pass null to fail fast.
|
||||||
|
actionItem.getFabImageDrawable(null))
|
||||||
|
.setLabel(actionItem.getLabel(context))
|
||||||
|
.setFabImageTintColor(fabImageTintColor.defaultColor)
|
||||||
|
.setFabBackgroundColor(fabBackgroundColor.defaultColor)
|
||||||
|
.setLabelColor(labelColor.defaultColor)
|
||||||
|
.setLabelBackgroundColor(labelBackgroundColor.defaultColor)
|
||||||
|
.setLabelClickable(actionItem.isLabelClickable)
|
||||||
|
.setTheme(actionItem.theme)
|
||||||
|
.create()
|
||||||
|
return super.addActionItem(actionItem, position, animate)?.apply {
|
||||||
|
fab.apply {
|
||||||
|
updateLayoutParams<MarginLayoutParams> {
|
||||||
|
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 =
|
||||||
|
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||||
|
fillColor = labelBackgroundColor
|
||||||
|
elevation = labelElevation
|
||||||
|
setCornerSize(cornerRadius.toFloat())
|
||||||
|
}
|
||||||
|
foreground = null
|
||||||
|
(getChildAt(0) as TextView).apply {
|
||||||
|
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_LabelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(): Parcelable {
|
||||||
|
val superState =
|
||||||
|
BundleCompat.getParcelable(
|
||||||
|
super.onSaveInstanceState() as Bundle, "superState", Parcelable::class.java)
|
||||||
|
return State(superState, isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreInstanceState(state: Parcelable) {
|
||||||
|
state as State
|
||||||
|
super.onRestoreInstanceState(state.superState)
|
||||||
|
if (state.isOpen) {
|
||||||
|
toggle(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setChangeListener(listener: ((Boolean) -> Unit)?) {
|
||||||
|
innerChangeListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val VIEW_PROPERTY_BACKGROUND_TINT =
|
||||||
|
object : Property<View, Int>(Int::class.java, "backgroundTint") {
|
||||||
|
override fun get(view: View): Int = view.backgroundTintList!!.defaultColor
|
||||||
|
|
||||||
|
override fun set(view: View, value: Int?) {
|
||||||
|
view.backgroundTintList = ColorStateList.valueOf(value!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val IMAGE_VIEW_PROPERTY_IMAGE_TINT =
|
||||||
|
object : Property<ImageView, Int>(Int::class.java, "imageTint") {
|
||||||
|
override fun get(view: ImageView): Int = view.imageTintList!!.defaultColor
|
||||||
|
|
||||||
|
override fun set(view: ImageView, value: Int?) {
|
||||||
|
view.imageTintList = ColorStateList.valueOf(value!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DRAWABLE_PROPERTY_LEVEL =
|
||||||
|
object : Property<Drawable, Int>(Int::class.java, "level") {
|
||||||
|
override fun get(drawable: Drawable): Int = drawable.level
|
||||||
|
|
||||||
|
override fun set(drawable: Drawable, value: Int?) {
|
||||||
|
drawable.level = value!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize private class State(val superState: Parcelable?, val isOpen: Boolean) : Parcelable
|
||||||
|
}
|
||||||
|
|
@ -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.fast_scroll_popup_min_width)
|
|
||||||
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
|
|
||||||
|
|
||||||
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(R.dimen.elevation_normal).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.fast_scroll_popup_padding_start)
|
|
||||||
private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
|
|
||||||
|
|
||||||
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.text.format.DateUtils
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.util.Formatter
|
import java.util.Formatter
|
||||||
|
|
@ -29,22 +31,23 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
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.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
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.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A [ListFragment] that shows a list of [Album]s.
|
||||||
|
|
@ -79,7 +82,16 @@ class AlbumListFragment :
|
||||||
listener = this@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.albumList, ::updateAlbums)
|
||||||
|
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -95,14 +107,14 @@ class AlbumListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
override fun getPopup(pos: Int): String? {
|
||||||
val album = homeModel.albumList.value[pos]
|
val album = homeModel.albumList.value.getOrNull(pos) ?: return null
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.albumSort.mode) {
|
return when (homeModel.albumSort.mode) {
|
||||||
// By Name -> Use Name
|
// 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
|
// 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)
|
// 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()) }
|
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||||
|
|
@ -115,7 +127,7 @@ class AlbumListFragment :
|
||||||
|
|
||||||
// Last added -> Format as date
|
// Last added -> Format as date
|
||||||
is Sort.Mode.ByDateAdded -> {
|
is Sort.Mode.ByDateAdded -> {
|
||||||
val dateAddedMillis = album.dateAdded.secsToMs()
|
val dateAddedMillis = album.addedMs
|
||||||
formatterSb.setLength(0)
|
formatterSb.setLength(0)
|
||||||
DateUtils.formatDateRange(
|
DateUtils.formatDateRange(
|
||||||
context,
|
context,
|
||||||
|
|
@ -147,6 +159,14 @@ class AlbumListFragment :
|
||||||
albumAdapter.update(albums, homeModel.albumInstructions.consume())
|
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>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,28 +21,31 @@ package org.oxycblt.auxio.home.list
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
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.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Artist
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.positiveOrNull
|
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.
|
* A [ListFragment] that shows a list of [Artist]s.
|
||||||
|
|
@ -74,7 +77,16 @@ class ArtistListFragment :
|
||||||
listener = this@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.artistList, ::updateArtists)
|
||||||
|
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -90,11 +102,11 @@ class ArtistListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
override fun getPopup(pos: Int): String? {
|
||||||
val artist = homeModel.artistList.value[pos]
|
val artist = homeModel.artistList.value.getOrNull(pos) ?: return null
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.artistSort.mode) {
|
return when (homeModel.artistSort.mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> artist.name.thumb
|
is Sort.Mode.ByName -> artist.name.thumb()
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||||
|
|
@ -123,6 +135,14 @@ class ArtistListFragment :
|
||||||
artistAdapter.update(artists, homeModel.artistInstructions.consume())
|
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>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,27 +21,30 @@ package org.oxycblt.auxio.home.list
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
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.recycler.GenreViewHolder
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Genre
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A [ListFragment] that shows a list of [Genre]s.
|
||||||
|
|
@ -73,7 +76,16 @@ class GenreListFragment :
|
||||||
listener = this@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.genreList, ::updateGenres)
|
||||||
|
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -89,11 +101,11 @@ class GenreListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
override fun getPopup(pos: Int): String? {
|
||||||
val genre = homeModel.genreList.value[pos]
|
val genre = homeModel.genreList.value.getOrNull(pos) ?: return null
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.genreSort.mode) {
|
return when (homeModel.genreSort.mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> genre.name.thumb
|
is Sort.Mode.ByName -> genre.name.thumb()
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||||
|
|
@ -122,6 +134,14 @@ class GenreListFragment :
|
||||||
genreAdapter.update(genres, homeModel.genreInstructions.consume())
|
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>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
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.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
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.recycler.PlaylistViewHolder
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
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.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A [ListFragment] that shows a list of [Playlist]s.
|
||||||
|
|
@ -71,7 +74,18 @@ class PlaylistListFragment :
|
||||||
listener = this@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.playlistList, ::updatePlaylists)
|
||||||
|
collectImmediately(
|
||||||
|
homeModel.empty,
|
||||||
|
homeModel.playlistList,
|
||||||
|
musicModel.indexingState,
|
||||||
|
::updateNoMusicIndicator)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -87,11 +101,11 @@ class PlaylistListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
override fun getPopup(pos: Int): String? {
|
||||||
val playlist = homeModel.playlistList.value[pos]
|
val playlist = homeModel.playlistList.value.getOrNull(pos) ?: return null
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
return when (homeModel.playlistSort.mode) {
|
return when (homeModel.playlistSort.mode) {
|
||||||
// By Name -> Use Name
|
// By Name -> Use Name
|
||||||
is Sort.Mode.ByName -> playlist.name.thumb
|
is Sort.Mode.ByName -> playlist.name.thumb()
|
||||||
|
|
||||||
// Duration -> Use formatted duration
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
|
||||||
|
|
@ -120,6 +134,26 @@ class PlaylistListFragment :
|
||||||
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
|
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>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,27 +22,30 @@ import android.os.Bundle
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isInvisible
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import java.util.Formatter
|
import java.util.Formatter
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||||
import org.oxycblt.auxio.home.HomeViewModel
|
import org.oxycblt.auxio.home.HomeViewModel
|
||||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
|
||||||
import org.oxycblt.auxio.list.ListFragment
|
import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.SelectableListListener
|
import org.oxycblt.auxio.list.SelectableListListener
|
||||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
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.recycler.SongViewHolder
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.auxio.music.IndexingState
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
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.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.playback.secsToMs
|
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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.
|
* A [ListFragment] that shows a list of [Song]s.
|
||||||
|
|
@ -59,6 +62,7 @@ class SongListFragment :
|
||||||
override val musicModel: MusicViewModel by activityViewModels()
|
override val musicModel: MusicViewModel by activityViewModels()
|
||||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||||
private val songAdapter = SongAdapter(this)
|
private val songAdapter = SongAdapter(this)
|
||||||
|
|
||||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||||
private val formatterSb = StringBuilder(64)
|
private val formatterSb = StringBuilder(64)
|
||||||
private val formatter = Formatter(formatterSb)
|
private val formatter = Formatter(formatterSb)
|
||||||
|
|
@ -76,7 +80,16 @@ class SongListFragment :
|
||||||
listener = this@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.songList, ::updateSongs)
|
||||||
|
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||||
collectImmediately(listModel.selected, ::updateSelection)
|
collectImmediately(listModel.selected, ::updateSelection)
|
||||||
collectImmediately(
|
collectImmediately(
|
||||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||||
|
|
@ -92,29 +105,29 @@ class SongListFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPopup(pos: Int): String? {
|
override fun getPopup(pos: Int): String? {
|
||||||
val song = homeModel.songList.value[pos]
|
val song = homeModel.songList.value.getOrNull(pos) ?: return null
|
||||||
// Change how we display the popup depending on the current sort mode.
|
// Change how we display the popup depending on the current sort mode.
|
||||||
// Note: We don't use the more correct individual artist name here, as sorts are largely
|
// Note: We don't use the more correct individual artist name here, as sorts are largely
|
||||||
// based off the names of the parent objects and not the child objects.
|
// based off the names of the parent objects and not the child objects.
|
||||||
return when (homeModel.songSort.mode) {
|
return when (homeModel.songSort.mode) {
|
||||||
// Name -> Use name
|
// Name -> Use name
|
||||||
is Sort.Mode.ByName -> song.name.thumb
|
is Sort.Mode.ByName -> song.name.thumb()
|
||||||
|
|
||||||
// Artist -> Use name of first artist
|
// 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
|
// Album -> Use Album Name
|
||||||
is Sort.Mode.ByAlbum -> song.album.name.thumb
|
is Sort.Mode.ByAlbum -> song.album.name.thumb()
|
||||||
|
|
||||||
// Year -> Use Full Year
|
// 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
|
// Duration -> Use formatted duration
|
||||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||||
|
|
||||||
// Last added -> Format as date
|
// Last added -> Format as date
|
||||||
is Sort.Mode.ByDateAdded -> {
|
is Sort.Mode.ByDateAdded -> {
|
||||||
val dateAddedMillis = song.dateAdded.secsToMs()
|
val dateAddedMillis = song.addedMs
|
||||||
formatterSb.setLength(0)
|
formatterSb.setLength(0)
|
||||||
DateUtils.formatDateRange(
|
DateUtils.formatDateRange(
|
||||||
context,
|
context,
|
||||||
|
|
@ -146,6 +159,14 @@ class SongListFragment :
|
||||||
songAdapter.update(songs, homeModel.songInstructions.consume())
|
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>) {
|
private fun updateSelection(selection: List<Music>) {
|
||||||
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
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
|
package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.util.logE
|
import timber.log.Timber as L
|
||||||
import org.oxycblt.auxio.util.logW
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A representation of a library tab suitable for configuration.
|
* 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.
|
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||||
val distinct = tabs.distinctBy { it.type }
|
val distinct = tabs.distinctBy { it.type }
|
||||||
if (tabs.size != distinct.size) {
|
if (tabs.size != distinct.size) {
|
||||||
logW(
|
L.w(
|
||||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
"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
|
// Make sure there are no duplicate tabs
|
||||||
val distinct = tabs.distinctBy { it.type }
|
val distinct = tabs.distinctBy { it.type }
|
||||||
if (tabs.size != distinct.size) {
|
if (tabs.size != distinct.size) {
|
||||||
logW(
|
L.w(
|
||||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
"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.
|
// For safety, return null if we have an empty or larger-than-expected tab array.
|
||||||
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.util.inflater
|
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.
|
* 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.
|
* @param newTabs The new array of tabs to show.
|
||||||
*/
|
*/
|
||||||
fun submitTabs(newTabs: Array<Tab>) {
|
fun submitTabs(newTabs: Array<Tab>) {
|
||||||
logD("Force-updating tab information")
|
L.d("Force-updating tab information")
|
||||||
tabs = newTabs
|
tabs = newTabs
|
||||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
||||||
* @param tab The new tab.
|
* @param tab The new tab.
|
||||||
*/
|
*/
|
||||||
fun setTab(at: Int, tab: 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
|
tabs[at] = tab
|
||||||
// Use a payload to avoid an item change animation.
|
// Use a payload to avoid an item change animation.
|
||||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
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.
|
* @param b The position of the second tab to swap.
|
||||||
*/
|
*/
|
||||||
fun swapTabs(a: Int, b: Int) {
|
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]
|
val tmp = tabs[b]
|
||||||
tabs[b] = tabs[a]
|
tabs[b] = tabs[a]
|
||||||
tabs[a] = tmp
|
tabs[a] = tmp
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||||
import org.oxycblt.auxio.home.HomeSettings
|
import org.oxycblt.auxio.home.HomeSettings
|
||||||
import org.oxycblt.auxio.list.EditClickListListener
|
import org.oxycblt.auxio.list.EditClickListListener
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
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]
|
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
|
||||||
|
|
@ -52,7 +52,7 @@ class TabCustomizeDialog :
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.set_lib_tabs)
|
.setTitle(R.string.set_lib_tabs)
|
||||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||||
logD("Committing tab changes")
|
L.d("Committing tab changes")
|
||||||
homeSettings.homeTabs = tabAdapter.tabs
|
homeSettings.homeTabs = tabAdapter.tabs
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
.setNegativeButton(R.string.lbl_cancel, null)
|
||||||
|
|
@ -99,7 +99,7 @@ class TabCustomizeDialog :
|
||||||
is Tab.Visible -> Tab.Invisible(old.type)
|
is Tab.Visible -> Tab.Invisible(old.type)
|
||||||
is Tab.Invisible -> Tab.Visible(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)
|
tabAdapter.setTab(index, new)
|
||||||
|
|
||||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
// 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.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import coil3.ImageLoader
|
||||||
import coil.ImageLoader
|
import coil3.request.Disposable
|
||||||
import coil.request.Disposable
|
import coil3.request.ImageRequest
|
||||||
import coil.request.ImageRequest
|
import coil3.size.Size
|
||||||
import coil.size.Size
|
import coil3.toBitmap
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
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.
|
* A utility to provide bitmaps in a race-less manner.
|
||||||
|
|
@ -94,7 +94,7 @@ constructor(
|
||||||
target
|
target
|
||||||
.onConfigRequest(
|
.onConfigRequest(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(listOf(song))
|
.data(song.cover)
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL))
|
.size(Size.ORIGINAL))
|
||||||
.target(
|
.target(
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,11 @@ import org.oxycblt.auxio.IntegerTable
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
enum class CoverMode {
|
enum class CoverMode {
|
||||||
/** Do not load album covers ("Off"). */
|
|
||||||
OFF,
|
OFF,
|
||||||
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
|
SAVE_SPACE,
|
||||||
MEDIA_STORE,
|
BALANCED,
|
||||||
/** Load high-quality covers directly from music files ("Quality"). */
|
HIGH_QUALITY,
|
||||||
QUALITY;
|
AS_IS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The integer representation of this instance.
|
* The integer representation of this instance.
|
||||||
|
|
@ -42,8 +41,10 @@ enum class CoverMode {
|
||||||
get() =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
OFF -> IntegerTable.COVER_MODE_OFF
|
OFF -> IntegerTable.COVER_MODE_OFF
|
||||||
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
|
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
|
||||||
QUALITY -> IntegerTable.COVER_MODE_QUALITY
|
BALANCED -> IntegerTable.COVER_MODE_BALANCED
|
||||||
|
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
|
||||||
|
AS_IS -> IntegerTable.COVER_MODE_AS_IS
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -57,8 +58,10 @@ enum class CoverMode {
|
||||||
fun fromIntCode(intCode: Int) =
|
fun fromIntCode(intCode: Int) =
|
||||||
when (intCode) {
|
when (intCode) {
|
||||||
IntegerTable.COVER_MODE_OFF -> OFF
|
IntegerTable.COVER_MODE_OFF -> OFF
|
||||||
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
|
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
|
||||||
IntegerTable.COVER_MODE_QUALITY -> QUALITY
|
IntegerTable.COVER_MODE_BALANCED -> BALANCED
|
||||||
|
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
|
||||||
|
IntegerTable.COVER_MODE_AS_IS -> AS_IS
|
||||||
else -> null
|
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
|
package org.oxycblt.auxio.image
|
||||||
|
|
||||||
import android.animation.ValueAnimator
|
import android.animation.Animator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
|
|
@ -33,35 +33,39 @@ import android.view.Gravity
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.annotation.DimenRes
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.core.content.res.getIntOrThrow
|
import androidx.annotation.Px
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
|
import androidx.core.view.isEmpty
|
||||||
import androidx.core.view.updateMarginsRelative
|
import androidx.core.view.updateMarginsRelative
|
||||||
import androidx.core.widget.ImageViewCompat
|
import androidx.core.widget.ImageViewCompat
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil3.asImage
|
||||||
import coil.util.CoilUtils
|
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.R as MR
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
|
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
|
||||||
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
|
import org.oxycblt.auxio.image.coil.SquareCropTransformation
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.ui.MaterialFader
|
||||||
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.ui.UISettings
|
import org.oxycblt.auxio.ui.UISettings
|
||||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.getColorCompat
|
import org.oxycblt.auxio.util.getColorCompat
|
||||||
import org.oxycblt.auxio.util.getDimen
|
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
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
|
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
|
||||||
|
|
@ -73,7 +77,7 @@ import org.oxycblt.auxio.util.getInteger
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class CoverView
|
open class CoverView
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||||
FrameLayout(context, attrs, defStyleAttr) {
|
FrameLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
@ -91,32 +95,41 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
|
|
||||||
private val playbackIndicator: PlaybackIndicator?
|
private val playbackIndicator: PlaybackIndicator?
|
||||||
private val selectionBadge: ImageView?
|
private val selectionBadge: ImageView?
|
||||||
|
private val iconSize: Int?
|
||||||
|
|
||||||
private val sizing: Int
|
private val fader = MaterialFader.quickLopsided(context)
|
||||||
@DimenRes private val iconSizeRes: Int?
|
private var fadeAnimator: Animator? = null
|
||||||
@DimenRes private var cornerRadiusRes: Int?
|
|
||||||
|
|
||||||
private var fadeAnimator: ValueAnimator? = null
|
|
||||||
private val indicatorMatrix = Matrix()
|
private val indicatorMatrix = Matrix()
|
||||||
private val indicatorMatrixSrc = RectF()
|
private val indicatorMatrixSrc = RectF()
|
||||||
private val indicatorMatrixDst = RectF()
|
private val indicatorMatrixDst = RectF()
|
||||||
|
|
||||||
private data class Cover(
|
private val shapeAppearance: ShapeAppearanceModel
|
||||||
val songs: Collection<Song>,
|
|
||||||
val desc: String,
|
|
||||||
@DrawableRes val errorRes: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
private var currentCover: Cover? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
||||||
@SuppressLint("CustomViewStyleable")
|
@SuppressLint("CustomViewStyleable")
|
||||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
|
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
|
||||||
|
|
||||||
sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
|
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0)
|
||||||
iconSizeRes = SIZING_ICON_SIZE[sizing]
|
shapeAppearance =
|
||||||
cornerRadiusRes = getCornerRadiusRes()
|
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().build()
|
||||||
|
}
|
||||||
|
iconSize =
|
||||||
|
styledAttrs.getDimensionPixelSize(R.styleable.CoverView_iconSize, -1).takeIf {
|
||||||
|
it != -1
|
||||||
|
}
|
||||||
|
|
||||||
val playbackIndicatorEnabled =
|
val playbackIndicatorEnabled =
|
||||||
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
|
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
|
||||||
|
|
@ -160,7 +173,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
super.onFinishInflate()
|
super.onFinishInflate()
|
||||||
|
|
||||||
// The image isn't added if other children have populated the body. This is by design.
|
// The image isn't added if other children have populated the body. This is by design.
|
||||||
if (childCount == 0) {
|
if (isEmpty()) {
|
||||||
addView(image)
|
addView(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +203,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
|
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
|
||||||
// behavior with a matrix.
|
// behavior with a matrix.
|
||||||
val playbackIndicator = (playbackIndicator ?: return).view
|
val playbackIndicator = (playbackIndicator ?: return).view
|
||||||
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
|
val iconSize = iconSize ?: (measuredWidth / 2)
|
||||||
playbackIndicator.apply {
|
playbackIndicator.apply {
|
||||||
imageMatrix =
|
imageMatrix =
|
||||||
indicatorMatrix.apply {
|
indicatorMatrix.apply {
|
||||||
|
|
@ -254,14 +267,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCornerRadiusRes() =
|
|
||||||
if (!isInEditMode && uiSettings.roundMode) {
|
|
||||||
SIZING_CORNER_RADII[sizing]
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyBackgroundsToChildren() {
|
private fun applyBackgroundsToChildren() {
|
||||||
|
|
||||||
// Add backgrounds to each child for visual consistency
|
// Add backgrounds to each child for visual consistency
|
||||||
for (child in children) {
|
for (child in children) {
|
||||||
child.apply {
|
child.apply {
|
||||||
|
|
@ -271,7 +278,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
background =
|
background =
|
||||||
MaterialShapeDrawable().apply {
|
MaterialShapeDrawable().apply {
|
||||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||||
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
shapeAppearanceModel = shapeAppearance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -297,43 +304,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
|
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
|
||||||
// Set up a target transition for the selection indicator.
|
fadeAnimator?.cancel()
|
||||||
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 =
|
fadeAnimator =
|
||||||
ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
|
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge))
|
||||||
duration = targetDuration
|
.also { it.start() }
|
||||||
addUpdateListener { selectionBadge.alpha = it.animatedValue as Float }
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -342,8 +316,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param song The [Song] to bind to the view.
|
* @param song The [Song] to bind to the view.
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song) =
|
fun bind(song: Song) =
|
||||||
bind(
|
bindImpl(
|
||||||
listOf(song),
|
song.cover,
|
||||||
context.getString(R.string.desc_album_cover, song.album.name),
|
context.getString(R.string.desc_album_cover, song.album.name),
|
||||||
R.drawable.ic_album_24)
|
R.drawable.ic_album_24)
|
||||||
|
|
||||||
|
|
@ -353,8 +327,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param album The [Album] to bind to the view.
|
* @param album The [Album] to bind to the view.
|
||||||
*/
|
*/
|
||||||
fun bind(album: Album) =
|
fun bind(album: Album) =
|
||||||
bind(
|
bindImpl(
|
||||||
album.songs,
|
album.covers,
|
||||||
context.getString(R.string.desc_album_cover, album.name),
|
context.getString(R.string.desc_album_cover, album.name),
|
||||||
R.drawable.ic_album_24)
|
R.drawable.ic_album_24)
|
||||||
|
|
||||||
|
|
@ -364,8 +338,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param artist The [Artist] to bind to the view.
|
* @param artist The [Artist] to bind to the view.
|
||||||
*/
|
*/
|
||||||
fun bind(artist: Artist) =
|
fun bind(artist: Artist) =
|
||||||
bind(
|
bindImpl(
|
||||||
artist.songs,
|
artist.covers,
|
||||||
context.getString(R.string.desc_artist_image, artist.name),
|
context.getString(R.string.desc_artist_image, artist.name),
|
||||||
R.drawable.ic_artist_24)
|
R.drawable.ic_artist_24)
|
||||||
|
|
||||||
|
|
@ -375,8 +349,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param genre The [Genre] to bind to the view.
|
* @param genre The [Genre] to bind to the view.
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre) =
|
fun bind(genre: Genre) =
|
||||||
bind(
|
bindImpl(
|
||||||
genre.songs,
|
genre.covers,
|
||||||
context.getString(R.string.desc_genre_image, genre.name),
|
context.getString(R.string.desc_genre_image, genre.name),
|
||||||
R.drawable.ic_genre_24)
|
R.drawable.ic_genre_24)
|
||||||
|
|
||||||
|
|
@ -386,8 +360,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param playlist the [Playlist] to bind.
|
* @param playlist the [Playlist] to bind.
|
||||||
*/
|
*/
|
||||||
fun bind(playlist: Playlist) =
|
fun bind(playlist: Playlist) =
|
||||||
bind(
|
bindImpl(
|
||||||
playlist.songs,
|
playlist.covers,
|
||||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||||
R.drawable.ic_playlist_24)
|
R.drawable.ic_playlist_24)
|
||||||
|
|
||||||
|
|
@ -398,15 +372,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param desc The content description to describe the bound data.
|
* @param desc The content description to describe the bound data.
|
||||||
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
||||||
*/
|
*/
|
||||||
fun bind(songs: Collection<Song>, desc: String, @DrawableRes errorRes: Int) {
|
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
||||||
|
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
|
||||||
|
|
||||||
|
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
|
||||||
val request =
|
val request =
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(songs)
|
.data(cover)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
|
.error(
|
||||||
|
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||||
|
.asImage())
|
||||||
.target(image)
|
.target(image)
|
||||||
|
|
||||||
val cornersTransformation =
|
val cornersTransformation =
|
||||||
RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
RoundedRectTransformation(
|
||||||
|
shapeAppearance.topLeftCornerSize.getCornerSize(
|
||||||
|
RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())))
|
||||||
if (imageSettings.forceSquareCovers) {
|
if (imageSettings.forceSquareCovers) {
|
||||||
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
|
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -417,7 +398,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
CoilUtils.dispose(image)
|
CoilUtils.dispose(image)
|
||||||
imageLoader.enqueue(request.build())
|
imageLoader.enqueue(request.build())
|
||||||
contentDescription = desc
|
contentDescription = desc
|
||||||
currentCover = Cover(songs, desc, errorRes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -427,7 +407,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
private class StyledDrawable(
|
private class StyledDrawable(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val inner: Drawable,
|
private val inner: Drawable,
|
||||||
@DimenRes iconSizeRes: Int?
|
@Px val iconSize: Int?
|
||||||
) : Drawable() {
|
) : Drawable() {
|
||||||
init {
|
init {
|
||||||
// Re-tint the drawable to use the analogous "on surface" color for
|
// Re-tint the drawable to use the analogous "on surface" color for
|
||||||
|
|
@ -435,12 +415,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dimen = iconSizeRes?.let(context::getDimenPixels)
|
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
override fun draw(canvas: Canvas) {
|
||||||
// Resize the drawable such that it's always 1/4 the size of the image and
|
// Resize the drawable such that it's always 1/4 the size of the image and
|
||||||
// centered in the middle of the canvas.
|
// centered in the middle of the canvas.
|
||||||
val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
|
val adj = iconSize?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
|
||||||
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
|
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
|
||||||
inner.draw(canvas)
|
inner.draw(canvas)
|
||||||
}
|
}
|
||||||
|
|
@ -457,11 +435,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
|
|
||||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
val SIZING_CORNER_RADII =
|
|
||||||
arrayOf(
|
|
||||||
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
|
|
||||||
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.auxio.util.logD
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User configuration specific to image loading.
|
* User configuration specific to image loading.
|
||||||
|
|
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
get() =
|
get() =
|
||||||
CoverMode.fromIntCode(
|
CoverMode.fromIntCode(
|
||||||
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
|
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
|
||||||
?: CoverMode.MEDIA_STORE
|
?: CoverMode.BALANCED
|
||||||
|
|
||||||
override val forceSquareCovers: Boolean
|
override val forceSquareCovers: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
|
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
|
// Show album covers and Ignore MediaStore covers were unified in 3.0.0
|
||||||
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
|
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
|
||||||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
|
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
|
||||||
logD("Migrating cover settings")
|
L.d("Migrating cover settings")
|
||||||
|
|
||||||
val mode =
|
val mode =
|
||||||
when {
|
when {
|
||||||
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
|
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
|
||||||
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
|
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
|
||||||
CoverMode.MEDIA_STORE
|
CoverMode.BALANCED
|
||||||
else -> CoverMode.QUALITY
|
else -> CoverMode.BALANCED
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
|
|
@ -74,12 +74,30 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
remove(OLD_KEY_QUALITY_COVERS)
|
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) {
|
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||||
if (key == getString(R.string.set_key_cover_mode) ||
|
if (key == getString(R.string.set_key_cover_mode) ||
|
||||||
key == getString(R.string.set_key_square_covers)) {
|
key == getString(R.string.set_key_square_covers)) {
|
||||||
logD("Dispatching image setting change")
|
L.d("Dispatching image setting change")
|
||||||
listener.onImageSettingsChanged()
|
listener.onImageSettingsChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,5 +105,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
private companion object {
|
private companion object {
|
||||||
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
|
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
|
||||||
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_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
|
* 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
|
* 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
|
* 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/>.
|
* 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 android.content.Context
|
||||||
import coil.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil.request.CachePolicy
|
import coil3.request.CachePolicy
|
||||||
|
import coil3.request.transitionFactory
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
|
@ -30,19 +31,22 @@ import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class ExtractorModule {
|
class CoilModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun imageLoader(
|
fun imageLoader(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
songKeyer: SongKeyer,
|
coverKeyer: CoverKeyer,
|
||||||
songFactory: SongCoverFetcher.Factory
|
coverFactory: CoverFetcher.Factory,
|
||||||
|
coverCollectionKeyer: CoverCollectionKeyer,
|
||||||
|
coverCollectionFactory: CoverCollectionFetcher.Factory
|
||||||
) =
|
) =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
// Add fetchers for Music components to make them usable with ImageRequest
|
add(coverKeyer)
|
||||||
add(songKeyer)
|
add(coverFactory)
|
||||||
add(songFactory)
|
add(coverCollectionKeyer)
|
||||||
|
add(coverCollectionFactory)
|
||||||
}
|
}
|
||||||
// Use our own crossfade with error drawable support
|
// Use our own crossfade with error drawable support
|
||||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
.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/>.
|
* 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 coil3.decode.DataSource
|
||||||
import coil.drawable.CrossfadeDrawable
|
import coil3.request.ImageResult
|
||||||
import coil.request.ImageResult
|
import coil3.request.SuccessResult
|
||||||
import coil.request.SuccessResult
|
import coil3.transition.CrossfadeDrawable
|
||||||
import coil.transition.CrossfadeTransition
|
import coil3.transition.CrossfadeTransition
|
||||||
import coil.transition.Transition
|
import coil3.transition.Transition
|
||||||
import coil.transition.TransitionTarget
|
import coil3.transition.TransitionTarget
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
|
* 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/>.
|
* 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
|
||||||
import android.graphics.Bitmap.createBitmap
|
import android.graphics.Bitmap.createBitmap
|
||||||
|
|
@ -30,16 +30,16 @@ import android.graphics.RectF
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import androidx.annotation.Px
|
import androidx.annotation.Px
|
||||||
import androidx.core.graphics.applyCanvas
|
import androidx.core.graphics.applyCanvas
|
||||||
import coil.decode.DecodeUtils
|
import coil3.decode.DecodeUtils
|
||||||
import coil.size.Scale
|
import coil3.size.Scale
|
||||||
import coil.size.Size
|
import coil3.size.Size
|
||||||
import coil.size.pxOrElse
|
import coil3.size.pxOrElse
|
||||||
import coil.transform.Transformation
|
import coil3.transform.Transformation
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
|
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
|
||||||
* images without cropping them.
|
* without cropping them.
|
||||||
*
|
*
|
||||||
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
|
|
@ -48,7 +48,7 @@ class RoundedRectTransformation(
|
||||||
@Px private val topRight: Float = 0f,
|
@Px private val topRight: Float = 0f,
|
||||||
@Px private val bottomLeft: Float = 0f,
|
@Px private val bottomLeft: Float = 0f,
|
||||||
@Px private val bottomRight: Float = 0f
|
@Px private val bottomRight: Float = 0f
|
||||||
) : Transformation {
|
) : Transformation() {
|
||||||
|
|
||||||
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
|
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
|
||||||
|
|
||||||
|
|
@ -65,7 +65,11 @@ class RoundedRectTransformation(
|
||||||
|
|
||||||
val (outputWidth, outputHeight) = calculateOutputSize(input, size)
|
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 {
|
output.applyCanvas {
|
||||||
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||||
|
|
||||||
|
|
@ -107,7 +111,10 @@ class RoundedRectTransformation(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
|
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 =
|
val multiplier =
|
||||||
DecodeUtils.computeSizeMultiplier(
|
DecodeUtils.computeSizeMultiplier(
|
||||||
srcWidth = input.width,
|
srcWidth = input.width,
|
||||||
|
|
@ -16,12 +16,13 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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
|
||||||
import coil.size.Size
|
import androidx.core.graphics.scale
|
||||||
import coil.size.pxOrElse
|
import coil3.size.Size
|
||||||
import coil.transform.Transformation
|
import coil3.size.pxOrElse
|
||||||
|
import coil3.transform.Transformation
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,7 +31,7 @@ import kotlin.math.min
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
class SquareCropTransformation : Transformation {
|
class SquareCropTransformation : Transformation() {
|
||||||
override val cacheKey: String
|
override val cacheKey: String
|
||||||
get() = "SquareCropTransformation"
|
get() = "SquareCropTransformation"
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ class SquareCropTransformation : Transformation {
|
||||||
val desiredHeight = size.height.pxOrElse { dstSize }
|
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||||
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||||
// Image is not the desired size, upscale it.
|
// Image is not the desired size, upscale it.
|
||||||
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
return dst.scale(desiredWidth, desiredHeight)
|
||||||
}
|
}
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* 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
|
* 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/>.
|
* 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.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -25,6 +25,6 @@ import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface DeviceModule {
|
interface CoilModule {
|
||||||
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
|
@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,48 +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
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
|
|
||||||
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
|
||||||
Keyer<Collection<Song>> {
|
|
||||||
override fun key(data: Collection<Song>, options: Options) =
|
|
||||||
"${coverExtractor.computeCoverOrdering(data).hashCode()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
class SongCoverFetcher
|
|
||||||
private constructor(
|
|
||||||
private val songs: Collection<Song>,
|
|
||||||
private val size: Size,
|
|
||||||
private val coverExtractor: CoverExtractor,
|
|
||||||
) : Fetcher {
|
|
||||||
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
|
||||||
|
|
||||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
|
||||||
Fetcher.Factory<Collection<Song>> {
|
|
||||||
override fun create(data: Collection<Song>, options: Options, imageLoader: ImageLoader) =
|
|
||||||
SongCoverFetcher(data, options.size, coverExtractor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,263 +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.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.list.sort.Sort
|
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
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 songs The [Song]s to load.
|
|
||||||
* @param size The [Size] of the image to load.
|
|
||||||
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
|
|
||||||
* will be returned of a mosaic composed of four album covers ordered by
|
|
||||||
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
|
|
||||||
*/
|
|
||||||
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? {
|
|
||||||
val albums = computeCoverOrdering(songs)
|
|
||||||
val streams = mutableListOf<InputStream>()
|
|
||||||
for (album in albums) {
|
|
||||||
openCoverInputStream(album)?.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an [Album] list representing the order that album covers would be used in [extract].
|
|
||||||
*
|
|
||||||
* @param songs A hypothetical list of [Song]s that would be used in [extract].
|
|
||||||
* @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then
|
|
||||||
* by their names. "Representation" is defined by how many [Song]s were found to be linked to
|
|
||||||
* the given [Album] in the given [Song] list.
|
|
||||||
*/
|
|
||||||
fun computeCoverOrdering(songs: Collection<Song>): List<Album> {
|
|
||||||
// TODO: Start short-circuiting in more places
|
|
||||||
if (songs.isEmpty()) return listOf()
|
|
||||||
if (songs.size == 1) return listOf(songs.first().album)
|
|
||||||
|
|
||||||
val sortedMap =
|
|
||||||
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
|
|
||||||
for (song in songs) {
|
|
||||||
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
|
|
||||||
}
|
|
||||||
return sortedMap.keys.sortedByDescending { sortedMap[it] }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun openCoverInputStream(album: Album) =
|
|
||||||
try {
|
|
||||||
when (imageSettings.coverMode) {
|
|
||||||
CoverMode.OFF -> null
|
|
||||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
|
|
||||||
CoverMode.QUALITY -> extractQualityCover(album)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logE("Unable to extract album cover due to an error: $e")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractQualityCover(album: Album) =
|
|
||||||
extractAospMetadataCover(album)
|
|
||||||
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
|
|
||||||
|
|
||||||
private fun extractAospMetadataCover(album: Album): InputStream? =
|
|
||||||
MediaMetadataRetriever().run {
|
|
||||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
|
||||||
// so it's probably fine not to wrap it.rmt
|
|
||||||
setDataSource(context, album.coverUri.song)
|
|
||||||
|
|
||||||
// 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(album: Album): InputStream? {
|
|
||||||
val tracks =
|
|
||||||
MetadataRetriever.retrieveMetadata(
|
|
||||||
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 extractMediaStoreCover(album: Album) =
|
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
context.contentResolver.openInputStream(album.coverUri.mediaStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,14 +22,16 @@ import androidx.annotation.StringRes
|
||||||
|
|
||||||
// TODO: Consider breaking this up into sealed classes for individual adapters
|
// 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. */
|
/** 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.
|
* A "header" used for delimiting groups of data.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface Header : Item {
|
interface PlainHeader : Header {
|
||||||
/** The string resource used for the header's title. */
|
/** The string resource used for the header's title. */
|
||||||
val titleRes: Int
|
val titleRes: Int
|
||||||
}
|
}
|
||||||
|
|
@ -40,12 +42,16 @@ interface Header : Item {
|
||||||
* @param titleRes The string resource used for the header's title.
|
* @param titleRes The string resource used for the header's title.
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @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.
|
* 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
|
* @param anchor The [PlainHeader] this divider should be next to in a list. Used as a way to
|
||||||
* divider continuity during list updates.
|
* 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.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.musikr.Music
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Fragment containing a selectable list.
|
* 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.list.sort.Sort
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
|
||||||
interface ListSettings : Settings<Unit> {
|
interface ListSettings : Settings<ListSettings.Listener> {
|
||||||
/** The [Sort] mode used in Song lists. */
|
/** The [Sort] mode used in Song lists. */
|
||||||
var songSort: Sort
|
var songSort: Sort
|
||||||
/** The [Sort] mode used in Album lists. */
|
/** The [Sort] mode used in Album lists. */
|
||||||
|
|
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
|
||||||
var artistSongSort: Sort
|
var artistSongSort: Sort
|
||||||
/** The [Sort] mode used in a Genre's Song list. */
|
/** The [Sort] mode used in a Genre's Song list. */
|
||||||
var genreSongSort: Sort
|
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) :
|
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
||||||
Settings.Impl<Unit>(context), ListSettings {
|
Settings.Impl<ListSettings.Listener>(context), ListSettings {
|
||||||
override var songSort: Sort
|
override var songSort: Sort
|
||||||
get() =
|
get() =
|
||||||
Sort.fromIntCode(
|
Sort.fromIntCode(
|
||||||
|
|
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
|
||||||
apply()
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.list.menu.Menu
|
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.MusicRepository
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.playback.PlaySong
|
import org.oxycblt.auxio.playback.PlaySong
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.musikr.Album
|
||||||
import org.oxycblt.auxio.util.logW
|
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.
|
* 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) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
val library = musicRepository.library ?: return
|
||||||
val userLibrary = musicRepository.userLibrary ?: return
|
|
||||||
// Sanitize the selection to remove items that no longer exist and thus
|
// Sanitize the selection to remove items that no longer exist and thus
|
||||||
// won't appear in any list.
|
// won't appear in any list.
|
||||||
_selected.value =
|
_selected.value =
|
||||||
_selected.value.mapNotNull {
|
_selected.value.mapNotNull {
|
||||||
when (it) {
|
when (it) {
|
||||||
is Song -> deviceLibrary.findSong(it.uid)
|
is Song -> library.findSong(it.uid)
|
||||||
is Album -> deviceLibrary.findAlbum(it.uid)
|
is Album -> library.findAlbum(it.uid)
|
||||||
is Artist -> deviceLibrary.findArtist(it.uid)
|
is Artist -> library.findArtist(it.uid)
|
||||||
is Genre -> deviceLibrary.findGenre(it.uid)
|
is Genre -> library.findGenre(it.uid)
|
||||||
is Playlist -> userLibrary.findPlaylist(it.uid)
|
is Playlist -> library.findPlaylist(it.uid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,16 +92,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
*/
|
*/
|
||||||
fun select(music: Music) {
|
fun select(music: Music) {
|
||||||
if (music is MusicParent && music.songs.isEmpty()) {
|
if (music is MusicParent && music.songs.isEmpty()) {
|
||||||
logD("Cannot select empty parent, ignoring operation")
|
L.d("Cannot select empty parent, ignoring operation")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val selected = _selected.value.toMutableList()
|
val selected = _selected.value.toMutableList()
|
||||||
if (!selected.remove(music)) {
|
if (!selected.remove(music)) {
|
||||||
logD("Adding $music to selection")
|
L.d("Adding $music to selection")
|
||||||
selected.add(music)
|
selected.add(music)
|
||||||
} else {
|
} else {
|
||||||
logD("Removed $music from selection")
|
L.d("Removed $music from selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
_selected.value = selected
|
_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.
|
* @return A list of [Song]s collated from each item selected.
|
||||||
*/
|
*/
|
||||||
fun takeSelection(): List<Song> {
|
fun takeSelection(): List<Song> {
|
||||||
logD("Taking selection")
|
L.d("Taking selection")
|
||||||
return peekSelection().also { _selected.value = listOf() }
|
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.
|
* @return true if the prior selection was non-empty, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun dropSelection(): Boolean {
|
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() }
|
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,7 +153,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
* should do.
|
* should do.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
|
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))
|
openImpl(Menu.ForSong(menuRes, song, playWith))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,7 +165,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
* @param album The [Album] to show.
|
* @param album The [Album] to show.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, album: Album) {
|
fun openMenu(@MenuRes menuRes: Int, album: Album) {
|
||||||
logD("Opening menu for $album")
|
L.d("Opening menu for $album")
|
||||||
openImpl(Menu.ForAlbum(menuRes, album))
|
openImpl(Menu.ForAlbum(menuRes, album))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +177,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
* @param artist The [Artist] to show.
|
* @param artist The [Artist] to show.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
|
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
|
||||||
logD("Opening menu for $artist")
|
L.d("Opening menu for $artist")
|
||||||
openImpl(Menu.ForArtist(menuRes, artist))
|
openImpl(Menu.ForArtist(menuRes, artist))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +189,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
* @param genre The [Genre] to show.
|
* @param genre The [Genre] to show.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
|
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
|
||||||
logD("Opening menu for $genre")
|
L.d("Opening menu for $genre")
|
||||||
openImpl(Menu.ForGenre(menuRes, genre))
|
openImpl(Menu.ForGenre(menuRes, genre))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +201,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
||||||
* @param playlist The [Playlist] to show.
|
* @param playlist The [Playlist] to show.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
|
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
|
||||||
logD("Opening menu for $playlist")
|
L.d("Opening menu for $playlist")
|
||||||
openImpl(Menu.ForPlaylist(menuRes, 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.
|
* @param songs The [Song] selection to show.
|
||||||
*/
|
*/
|
||||||
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
|
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))
|
openImpl(Menu.ForSelection(menuRes, songs))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openImpl(menu: Menu) {
|
private fun openImpl(menu: Menu) {
|
||||||
val existing = _menu.flow.value
|
val existing = _menu.flow.value
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
logW("Already opening $existing, ignoring $menu")
|
L.w("Already opening $existing, ignoring $menu")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_menu.put(menu)
|
_menu.put(menu)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import java.util.concurrent.Executor
|
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.
|
* A variant of ListDiffer with more flexible updates.
|
||||||
|
|
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
instructions: UpdateInstructions?,
|
instructions: UpdateInstructions?,
|
||||||
callback: (() -> Unit)? = null
|
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)
|
differ.update(newList, instructions, callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
|
||||||
) {
|
) {
|
||||||
// fast simple remove all
|
// fast simple remove all
|
||||||
if (newList.isEmpty()) {
|
if (newList.isEmpty()) {
|
||||||
logD("Short-circuiting diff to remove all")
|
L.d("Short-circuiting diff to remove all")
|
||||||
val countRemoved = oldList.size
|
val countRemoved = oldList.size
|
||||||
currentList = emptyList()
|
currentList = emptyList()
|
||||||
// notify last, after list is updated
|
// notify last, after list is updated
|
||||||
|
|
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
|
||||||
|
|
||||||
// fast simple first insert
|
// fast simple first insert
|
||||||
if (oldList.isEmpty()) {
|
if (oldList.isEmpty()) {
|
||||||
logD("Short-circuiting diff to insert all")
|
L.d("Short-circuiting diff to insert all")
|
||||||
currentList = newList
|
currentList = newList
|
||||||
// notify last, after list is updated
|
// notify last, after list is updated
|
||||||
updateCallback.onInserted(0, newList.size)
|
updateCallback.onInserted(0, newList.size)
|
||||||
|
|
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
|
||||||
|
|
||||||
mainThreadExecutor.execute {
|
mainThreadExecutor.execute {
|
||||||
if (maxScheduledGeneration == runGeneration) {
|
if (maxScheduledGeneration == runGeneration) {
|
||||||
logD("Applying calculated diff")
|
L.d("Applying calculated diff")
|
||||||
currentList = newList
|
currentList = newList
|
||||||
result.dispatchUpdatesTo(updateCallback)
|
result.dispatchUpdatesTo(updateCallback)
|
||||||
callback?.invoke()
|
callback?.invoke()
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ package org.oxycblt.auxio.list.adapter
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.util.logD
|
import timber.log.Timber as L
|
||||||
import org.oxycblt.auxio.util.logW
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
* 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.
|
* @param isPlaying Whether playback is ongoing or paused.
|
||||||
*/
|
*/
|
||||||
fun setPlaying(item: T?, isPlaying: Boolean) {
|
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
|
var updatedItem = false
|
||||||
if (currentItem != item) {
|
if (currentItem != item) {
|
||||||
|
|
@ -72,7 +71,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
||||||
if (pos > -1) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} 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) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} 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) {
|
if (pos > -1) {
|
||||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||||
} else {
|
} 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 android.view.View
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.oxycblt.auxio.music.Music
|
import org.oxycblt.musikr.Music
|
||||||
import org.oxycblt.auxio.util.logD
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
* 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.
|
// Nothing to do.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||||
|
|
||||||
selectedItems = newSelectedItems
|
selectedItems = newSelectedItems
|
||||||
for (i in currentList.indices) {
|
for (i in currentList.indices) {
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.annotation.MenuRes
|
import androidx.annotation.MenuRes
|
||||||
import kotlinx.parcelize.Parcelize
|
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.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.
|
* 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.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
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
|
* 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?) {
|
private fun updateMenu(menu: Menu?) {
|
||||||
if (menu == null) {
|
if (menu == null) {
|
||||||
logD("No menu to show, navigating away")
|
L.d("No menu to show, navigating away")
|
||||||
findNavController().navigateUp()
|
findNavController().navigateUp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,18 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
||||||
import org.oxycblt.auxio.detail.DetailViewModel
|
import org.oxycblt.auxio.detail.DetailViewModel
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
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.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.util.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.share
|
import org.oxycblt.auxio.util.share
|
||||||
import org.oxycblt.auxio.util.showToast
|
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].
|
* [MenuDialogFragment] implementation for a [Song].
|
||||||
|
|
@ -112,7 +113,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
|
||||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
||||||
val context = requireContext()
|
val context = requireContext()
|
||||||
binding.menuCover.bind(menu.album)
|
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.menuName.text = menu.album.name.resolve(context)
|
||||||
binding.menuInfo.text = menu.album.artists.resolveNames(context)
|
binding.menuInfo.text = menu.album.artists.resolveNames(context)
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +179,11 @@ class ArtistMenuDialogFragment : MenuDialogFragment<Menu.ForArtist>() {
|
||||||
binding.menuInfo.text =
|
binding.menuInfo.text =
|
||||||
getString(
|
getString(
|
||||||
R.string.fmt_two,
|
R.string.fmt_two,
|
||||||
context.getPlural(R.plurals.fmt_album_count, menu.artist.albums.size),
|
if (menu.artist.explicitAlbums.isNotEmpty()) {
|
||||||
|
context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.def_album_count)
|
||||||
|
},
|
||||||
if (menu.artist.songs.isNotEmpty()) {
|
if (menu.artist.songs.isNotEmpty()) {
|
||||||
context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size)
|
context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -284,6 +289,7 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
|
||||||
R.id.action_play_next,
|
R.id.action_play_next,
|
||||||
R.id.action_queue_add,
|
R.id.action_queue_add,
|
||||||
R.id.action_playlist_add,
|
R.id.action_playlist_add,
|
||||||
|
R.id.action_export,
|
||||||
R.id.action_share)
|
R.id.action_share)
|
||||||
} else {
|
} else {
|
||||||
setOf()
|
setOf()
|
||||||
|
|
@ -316,6 +322,8 @@ class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
|
||||||
requireContext().showToast(R.string.lng_queue_added)
|
requireContext().showToast(R.string.lng_queue_added)
|
||||||
}
|
}
|
||||||
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
|
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
|
||||||
|
R.id.action_import -> musicModel.importPlaylist(target = menu.playlist)
|
||||||
|
R.id.action_export -> musicModel.exportPlaylist(menu.playlist)
|
||||||
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
|
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
|
||||||
R.id.action_share -> requireContext().share(menu.playlist)
|
R.id.action_share -> requireContext().share(menu.playlist)
|
||||||
else -> error("Unexpected menu item $item")
|
else -> error("Unexpected menu item $item")
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
|
||||||
oldItem == newItem
|
oldItem == newItem
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
|
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 javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.playback.PlaySong
|
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.
|
* Manages the state information for [MenuDialogFragment] implementations.
|
||||||
|
|
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
|
||||||
fun setMenu(parcel: Menu.Parcel) {
|
fun setMenu(parcel: Menu.Parcel) {
|
||||||
_currentMenu.value = unpackParcel(parcel)
|
_currentMenu.value = unpackParcel(parcel)
|
||||||
if (_currentMenu.value == null) {
|
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? {
|
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 parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
|
||||||
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
|
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
|
||||||
return Menu.ForSong(parcel.res, song, playWith)
|
return Menu.ForSong(parcel.res, song, playWith)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
|
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)
|
return Menu.ForAlbum(parcel.res, album)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
|
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)
|
return Menu.ForArtist(parcel.res, artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
|
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)
|
return Menu.ForGenre(parcel.res, genre)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
|
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)
|
return Menu.ForPlaylist(parcel.res, playlist)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
|
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
val library = musicRepository.library ?: return null
|
||||||
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
|
val songs = parcel.songUids.mapNotNull(library::findSong)
|
||||||
return Menu.ForSelection(parcel.res, songs)
|
return Menu.ForSelection(parcel.res, songs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
package org.oxycblt.auxio.list.recycler
|
package org.oxycblt.auxio.list.recycler
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
|
|
@ -38,6 +39,7 @@ open class AuxioRecyclerView
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||||
RecyclerView(context, attrs, defStyleAttr) {
|
RecyclerView(context, attrs, defStyleAttr) {
|
||||||
private val initialPaddingBottom = paddingBottom
|
private val initialPaddingBottom = paddingBottom
|
||||||
|
private var savedState: Parcelable? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Prevent children from being clipped by window insets
|
// 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
|
// Update the RecyclerView's padding such that the bottom insets are applied
|
||||||
// while still preserving bottom padding.
|
// while still preserving bottom padding.
|
||||||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
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
|
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/>.
|
* 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.content.Context
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.TextUtils
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
|
|
@ -30,16 +35,22 @@ import android.view.ViewGroup
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.core.view.isEmpty
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.core.view.updatePaddingRelative
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.core.widget.TextViewCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.textview.MaterialTextView
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import org.oxycblt.auxio.R
|
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.getDimenPixels
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
import org.oxycblt.auxio.util.getInteger
|
import org.oxycblt.auxio.util.inflater
|
||||||
import org.oxycblt.auxio.util.isRtl
|
import org.oxycblt.auxio.util.isRtl
|
||||||
import org.oxycblt.auxio.util.isUnder
|
import org.oxycblt.auxio.util.isUnder
|
||||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
|
|
@ -66,52 +77,73 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* - Variable names are no longer prefixed with m
|
* - Variable names are no longer prefixed with m
|
||||||
* - Added drag listener
|
* - Added drag listener
|
||||||
* - Added documentation
|
* - Added documentation
|
||||||
|
* - Completely new design
|
||||||
|
* - New scroll position backend
|
||||||
*
|
*
|
||||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
* @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
|
class FastScrollRecyclerView
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||||
// Thumb
|
// Thumb
|
||||||
private val thumbView =
|
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
|
||||||
View(context).apply {
|
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
||||||
alpha = 0f
|
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
||||||
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
|
private var thumbAnimator: Animator? = null
|
||||||
}
|
|
||||||
|
|
||||||
private val thumbWidth = thumbView.background.intrinsicWidth
|
@SuppressLint("InflateParams")
|
||||||
private val thumbHeight = thumbView.background.intrinsicHeight
|
private val thumbView =
|
||||||
|
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
|
||||||
|
thumbSlider.jumpOut(this)
|
||||||
|
}
|
||||||
private val thumbPadding = Rect(0, 0, 0, 0)
|
private val thumbPadding = Rect(0, 0, 0, 0)
|
||||||
private var thumbOffset = 0
|
private var thumbOffset = 0
|
||||||
|
|
||||||
private var showingThumb = false
|
private var showingThumb = false
|
||||||
private val hideThumbRunnable = Runnable {
|
private val hideThumbRunnable = Runnable {
|
||||||
if (!dragging) {
|
if (!dragging) {
|
||||||
hideScrollbar()
|
hideThumb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popup
|
|
||||||
private val popupView =
|
private val popupView =
|
||||||
FastScrollPopupView(context).apply {
|
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 =
|
layoutParams =
|
||||||
FrameLayout.LayoutParams(
|
FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
.apply {
|
.apply {
|
||||||
|
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||||||
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val popupSlider =
|
||||||
|
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
|
||||||
|
jumpOut(popupView)
|
||||||
|
}
|
||||||
|
private var popupAnimator: Animator? = null
|
||||||
private var showingPopup = false
|
private var showingPopup = false
|
||||||
|
|
||||||
// Touch
|
// Touch
|
||||||
private val minTouchTargetSize =
|
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
|
|
||||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||||
|
|
||||||
private var downX = 0f
|
private var downX = 0f
|
||||||
|
|
@ -120,6 +152,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
private var dragStartY = 0f
|
private var dragStartY = 0f
|
||||||
private var dragStartThumbOffset = 0
|
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
|
private var dragging = false
|
||||||
set(value) {
|
set(value) {
|
||||||
if (field == value) {
|
if (field == value) {
|
||||||
|
|
@ -139,15 +189,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
showScrollbar()
|
showScrollbar()
|
||||||
showPopup()
|
showPopup()
|
||||||
} else {
|
} else {
|
||||||
postAutoHideScrollbar()
|
|
||||||
hidePopup()
|
hidePopup()
|
||||||
|
postAutoHideScrollbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
listener?.onFastScrollingChanged(field)
|
listener?.onFastScrollingChanged(field)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tRect = Rect()
|
|
||||||
|
|
||||||
var popupProvider: PopupProvider? = null
|
var popupProvider: PopupProvider? = null
|
||||||
var listener: Listener? = null
|
var listener: Listener? = null
|
||||||
|
|
||||||
|
|
@ -182,22 +230,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
// --- RECYCLERVIEW EVENT MANAGEMENT ---
|
// --- RECYCLERVIEW EVENT MANAGEMENT ---
|
||||||
|
|
||||||
private fun onPreDraw() {
|
private fun onPreDraw() {
|
||||||
updateScrollbarState()
|
updateThumbState()
|
||||||
|
|
||||||
thumbView.layoutDirection = layoutDirection
|
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 =
|
val thumbLeft =
|
||||||
if (isRtl) {
|
if (isRtl) {
|
||||||
thumbPadding.left
|
thumbPadding.left
|
||||||
} else {
|
} else {
|
||||||
width - thumbPadding.right - thumbWidth
|
width - thumbPadding.right - thumbWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
val thumbTop = thumbPadding.top + thumbOffset
|
|
||||||
|
|
||||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||||
|
|
||||||
|
popupView.layoutDirection = layoutDirection
|
||||||
val child = getChildAt(0)
|
val child = getChildAt(0)
|
||||||
val firstAdapterPos =
|
val firstAdapterPos =
|
||||||
if (child != null) {
|
if (child != null) {
|
||||||
|
|
@ -214,10 +262,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
||||||
} else {
|
} else {
|
||||||
// No valid position or provider, do not show the popup.
|
// No valid position or provider, do not show the popup.
|
||||||
popupView.isInvisible = true
|
popupView.isInvisible = false
|
||||||
popupText = ""
|
popupText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
||||||
|
|
||||||
if (popupView.text != popupText) {
|
if (popupView.text != popupText) {
|
||||||
|
|
@ -243,6 +290,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
popupLayoutParams.height)
|
popupLayoutParams.height)
|
||||||
|
|
||||||
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
if (showingPopup) {
|
||||||
|
doPopupVibration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val popupWidth = popupView.measuredWidth
|
val popupWidth = popupView.measuredWidth
|
||||||
|
|
@ -255,7 +305,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
val popupAnchorY = popupHeight / 2
|
val popupAnchorY = popupHeight / 2
|
||||||
val thumbAnchorY = thumbView.paddingTop
|
val thumbAnchorY = thumbView.height / 2
|
||||||
|
|
||||||
val popupTop =
|
val popupTop =
|
||||||
(thumbTop + thumbAnchorY - popupAnchorY)
|
(thumbTop + thumbAnchorY - popupAnchorY)
|
||||||
|
|
@ -269,7 +319,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
override fun onScrolled(dx: Int, dy: Int) {
|
override fun onScrolled(dx: Int, dy: Int) {
|
||||||
super.onScrolled(dx, dy)
|
super.onScrolled(dx, dy)
|
||||||
|
|
||||||
updateScrollbarState()
|
updateThumbState()
|
||||||
|
|
||||||
// Measure or layout events result in a fake onScrolled call. Ignore those.
|
// Measure or layout events result in a fake onScrolled call. Ignore those.
|
||||||
if (dx == 0 && dy == 0) {
|
if (dx == 0 && dy == 0) {
|
||||||
|
|
@ -287,30 +337,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
return insets
|
return insets
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateScrollbarState() {
|
private fun updateThumbState() {
|
||||||
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
|
|
||||||
|
|
||||||
// Then calculate the thumb position, which is just:
|
// Then calculate the thumb position, which is just:
|
||||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
// [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 {
|
private fun onItemTouch(event: MotionEvent): Boolean {
|
||||||
|
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||||
|
dragging = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
val eventX = event.x
|
val eventX = event.x
|
||||||
val eventY = event.y
|
val eventY = event.y
|
||||||
|
|
||||||
|
|
@ -324,10 +371,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
|
|
||||||
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
|
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
|
||||||
dragStartThumbOffset = thumbOffset
|
dragStartThumbOffset = thumbOffset
|
||||||
} else {
|
} else if (eventX > thumbView.right - thumbWidth / 4) {
|
||||||
dragStartThumbOffset =
|
dragStartThumbOffset =
|
||||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||||
scrollToThumbOffset(dragStartThumbOffset)
|
scrollToThumbOffset(dragStartThumbOffset)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging = true
|
dragging = true
|
||||||
|
|
@ -364,44 +413,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scrollToThumbOffset(thumbOffset: Int) {
|
private fun scrollToThumbOffset(thumbOffset: Int) {
|
||||||
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
|
||||||
|
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||||
val scrollOffset =
|
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
|
||||||
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
|
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||||
paddingTop
|
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
|
||||||
|
if (newOffsetY == 0f) {
|
||||||
scrollTo(scrollOffset)
|
// Hacky workaround to drift in vertical scroll offset where we just snap
|
||||||
}
|
// to the top if the thumb offset hit zero.
|
||||||
|
scrollToPosition(0)
|
||||||
private fun scrollTo(offset: Int) {
|
|
||||||
if (childCount == 0) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val dy = newOffsetY - previousOffsetY
|
||||||
stopScroll()
|
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SCROLLBAR APPEARANCE ---
|
// --- SCROLLBAR APPEARANCE ---
|
||||||
|
|
@ -412,30 +436,39 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showScrollbar() {
|
private fun showScrollbar() {
|
||||||
|
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (showingThumb) {
|
if (showingThumb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showingThumb = true
|
showingThumb = true
|
||||||
animateViewIn(thumbView)
|
thumbAnimator?.cancel()
|
||||||
|
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideScrollbar() {
|
private fun hideThumb() {
|
||||||
if (!showingThumb) {
|
if (!showingThumb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showingThumb = false
|
showingThumb = false
|
||||||
animateViewOut(thumbView)
|
thumbAnimator?.cancel()
|
||||||
|
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showPopup() {
|
private fun showPopup() {
|
||||||
|
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (showingPopup) {
|
if (showingPopup) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showingPopup = true
|
showingPopup = true
|
||||||
animateViewIn(popupView)
|
popupAnimator?.cancel()
|
||||||
|
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hidePopup() {
|
private fun hidePopup() {
|
||||||
|
|
@ -444,23 +477,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
showingPopup = false
|
showingPopup = false
|
||||||
animateViewOut(popupView)
|
popupAnimator?.cancel()
|
||||||
|
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun animateViewIn(view: View) {
|
private fun doPopupVibration() {
|
||||||
view
|
performHapticFeedback(
|
||||||
.animate()
|
if (Build.VERSION.SDK_INT >= 27) {
|
||||||
.alpha(1f)
|
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||||
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
|
} else {
|
||||||
.start()
|
HapticFeedbackConstants.KEYBOARD_TAP
|
||||||
}
|
})
|
||||||
|
|
||||||
private fun animateViewOut(view: View) {
|
|
||||||
view
|
|
||||||
.animate()
|
|
||||||
.alpha(0f)
|
|
||||||
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
|
|
||||||
.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LAYOUT STATE ---
|
// --- LAYOUT STATE ---
|
||||||
|
|
@ -470,45 +497,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
|
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. */
|
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||||
interface PopupProvider {
|
interface PopupProvider {
|
||||||
/**
|
/**
|
||||||
|
|
@ -532,6 +520,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -25,11 +25,16 @@ import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isInvisible
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.R as MR
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sign
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
||||||
import org.oxycblt.auxio.util.getDimen
|
import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.getInteger
|
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,
|
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
|
||||||
|
|
@ -53,6 +58,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun interpolateOutOfBoundsScroll(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
viewSize: Int,
|
||||||
|
viewSizeOutOfBounds: Int,
|
||||||
|
totalSize: Int,
|
||||||
|
msSinceStartScroll: Long
|
||||||
|
): Int {
|
||||||
|
// Clamp the scroll speed to prevent the lists from freaking out
|
||||||
|
// Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe
|
||||||
|
val standardSpeed =
|
||||||
|
super.interpolateOutOfBoundsScroll(
|
||||||
|
recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll)
|
||||||
|
|
||||||
|
val clampedAbsVelocity =
|
||||||
|
max(
|
||||||
|
MINIMUM_INITIAL_DRAG_VELOCITY,
|
||||||
|
min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY))
|
||||||
|
|
||||||
|
return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
final override fun onChildDraw(
|
final override fun onChildDraw(
|
||||||
c: Canvas,
|
c: Canvas,
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
|
|
@ -66,12 +92,11 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
|
|
||||||
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
|
// 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.
|
// 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) {
|
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||||
logD("Lifting ViewHolder")
|
L.d("Lifting ViewHolder")
|
||||||
|
|
||||||
val bg = holder.background
|
val bg = holder.background
|
||||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||||
holder.root
|
holder.root
|
||||||
.animate()
|
.animate()
|
||||||
.translationZ(elevation)
|
.translationZ(elevation)
|
||||||
|
|
@ -110,10 +135,10 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
// This function can be called multiple times, so only start the animation when the view's
|
// This function can be called multiple times, so only start the animation when the view's
|
||||||
// translationZ is already non-zero.
|
// translationZ is already non-zero.
|
||||||
if (holder.root.translationZ != 0f) {
|
if (holder.root.translationZ != 0f) {
|
||||||
logD("Lifting ViewHolder")
|
L.d("Lifting ViewHolder")
|
||||||
|
|
||||||
val bg = holder.background
|
val bg = holder.background
|
||||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||||
holder.root
|
holder.root
|
||||||
.animate()
|
.animate()
|
||||||
.translationZ(0f)
|
.translationZ(0f)
|
||||||
|
|
@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
||||||
/** The drawable of the [body] background that can be elevated. */
|
/** The drawable of the [body] background that can be elevated. */
|
||||||
val background: Drawable
|
val background: Drawable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MINIMUM_INITIAL_DRAG_VELOCITY = 10
|
||||||
|
const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue