Compare commits
556 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 |
426 changed files with 14789 additions and 12096 deletions
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,6 +34,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: What android version do you use?
|
label: What android version do you use?
|
||||||
options:
|
options:
|
||||||
|
- Android 15
|
||||||
- Android 14
|
- Android 14
|
||||||
- Android 13
|
- Android 13
|
||||||
- Android 12L
|
- Android 12L
|
||||||
|
|
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
|
@ -25,8 +25,10 @@ jobs:
|
||||||
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
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -14,3 +14,5 @@ captures/
|
||||||
*.iml
|
*.iml
|
||||||
.cxx
|
.cxx
|
||||||
.kotlin
|
.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
|
||||||
|
|
84
CHANGELOG.md
84
CHANGELOG.md
|
@ -1,30 +1,100 @@
|
||||||
# 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
|
## 4.0.0
|
||||||
|
|
||||||
#### What's New
|
#### 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
|
- Android 15 support
|
||||||
- New app branding and icon
|
|
||||||
- Refreshed playback design
|
|
||||||
- Live widget preview on Android 15+
|
|
||||||
- Added GitHub/email feedback forms to about page
|
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Album grouping no longer done with artist in mind by default
|
- 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
|
- 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
|
- 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
|
- Sorting songs by date now uses songs date first, before the earliest album date
|
||||||
- Added working layouts for small split-screen form factors
|
- 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
|
#### What's Fixed
|
||||||
- Music loader no longer spawns thousands of threads when scanning
|
|
||||||
- Excessive CPU no longer spent showing music loading process
|
|
||||||
- Fixed playback sheet flickering on warm start
|
- Fixed playback sheet flickering on warm start
|
||||||
- No longer possible to save a sort with no direction specified
|
- No longer possible to save a sort with no direction specified
|
||||||
- Fixed inconsistent corner radii in widget
|
- 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
|
#### Dev/Meta
|
||||||
- No longer using custom logging setup
|
- No longer using custom logging setup
|
||||||
|
- Music loading split off into separate musikr module
|
||||||
|
|
||||||
## 3.6.3
|
## 3.6.3
|
||||||
|
|
||||||
|
|
43
README.md
43
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.6.3">
|
<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.6.3&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">
|
||||||
|
@ -15,7 +15,12 @@
|
||||||
</p>
|
</p>
|
||||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
|
<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://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>
|
<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>
|
||||||
|
|
||||||
|
@ -28,14 +33,12 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
|
||||||
## 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>
|
||||||
|
|
||||||
|
|
||||||
|
@ -61,29 +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 (by default)
|
- No rounded album covers (if you want them)
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music 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 in the background
|
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
|
||||||
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||||
|
|
||||||
## Donate
|
## 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!
|
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"><b>$8/month supporters:</b></p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
|
||||||
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=50 /></a>
|
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
|
||||||
|
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
|
||||||
</p>
|
</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.
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -12,20 +11,18 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk 35
|
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 "26.3.11579264"
|
|
||||||
namespace "org.oxycblt.auxio"
|
namespace "org.oxycblt.auxio"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId namespace
|
applicationId namespace
|
||||||
versionName "3.6.3"
|
versionName "4.0.4"
|
||||||
versionCode 53
|
versionCode 63
|
||||||
|
|
||||||
minSdk 24
|
minSdk min_sdk
|
||||||
targetSdk 35
|
targetSdk target_sdk
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
@ -80,14 +77,13 @@ 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.15.0"
|
implementation "androidx.core:core-ktx:$core_version"
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation "androidx.activity:activity-ktx:1.9.3"
|
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||||
// noinspection GradleDependency
|
// noinspection GradleDependency
|
||||||
|
@ -125,20 +121,26 @@ dependencies {
|
||||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
def room_version = '2.6.1'
|
|
||||||
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)
|
||||||
implementation project(":media-lib-exoplayer")
|
implementation project(":media-lib-exoplayer")
|
||||||
implementation project(":media-lib-decoder-ffmpeg")
|
implementation project(":media-lib-decoder-ffmpeg")
|
||||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.3"
|
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -162,25 +164,4 @@ dependencies {
|
||||||
|
|
||||||
// Fuzzy search
|
// Fuzzy search
|
||||||
implementation 'org.apache.commons:commons-text:1.9'
|
implementation 'org.apache.commons:commons-text:1.9'
|
||||||
|
|
||||||
// Testing
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
|
||||||
testImplementation "junit:junit:4.13.2"
|
|
||||||
testImplementation "io.mockk:mockk:1.13.7"
|
|
||||||
testImplementation "org.robolectric:robolectric:4.11"
|
|
||||||
testImplementation 'androidx.test:core-ktx:1.6.1'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
|
||||||
}
|
|
||||||
|
|
||||||
spotless {
|
|
||||||
kotlin {
|
|
||||||
target "src/**/*.kt"
|
|
||||||
ktfmt().dropboxStyle()
|
|
||||||
licenseHeaderFile("NOTICE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEvaluate {
|
|
||||||
preDebugBuild.dependsOn spotlessApply
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 +2,6 @@
|
||||||
<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" />
|
||||||
|
@ -100,6 +97,15 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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.
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
|
@ -1309,7 +1309,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
||||||
+ " should not be set externally.");
|
+ " 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;
|
||||||
|
@ -1633,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 && isHideableWhenDragging()) {
|
if (canActuallyHide) {
|
||||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||||
backEvent,
|
backEvent,
|
||||||
new AnimatorListenerAdapter() {
|
new AnimatorListenerAdapter() {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AuxioService :
|
class AuxioService :
|
||||||
|
@ -53,24 +54,30 @@ class AuxioService :
|
||||||
musicFragment = musicFragmentFactory.create(this, this, this)
|
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||||
sessionToken = playbackFragment.attach()
|
sessionToken = playbackFragment.attach()
|
||||||
musicFragment.attach()
|
musicFragment.attach()
|
||||||
|
Timber.d("Service Created")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||||
// service, we might need more handling here.
|
// service, we might need more handling here.
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
onHandleForeground(intent)
|
onHandleForeground(intent)
|
||||||
return super.onStartCommand(intent, flags, startId)
|
// 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? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
val binder = super.onBind(intent)
|
||||||
onHandleForeground(intent)
|
onHandleForeground(intent)
|
||||||
return super.onBind(intent)
|
return binder
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onHandleForeground(intent: Intent?) {
|
private fun onHandleForeground(intent: Intent?) {
|
||||||
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
|
||||||
musicFragment.start()
|
musicFragment.start()
|
||||||
playbackFragment.start(startId)
|
playbackFragment.start(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
@ -134,6 +141,7 @@ class AuxioService :
|
||||||
}
|
}
|
||||||
// Nothing changed, but don't show anything music related since we can always
|
// Nothing changed, but don't show anything music related since we can always
|
||||||
// index during playback.
|
// index during playback.
|
||||||
|
isForeground = true
|
||||||
} else {
|
} else {
|
||||||
musicFragment.createNotification {
|
musicFragment.createNotification {
|
||||||
if (it != null) {
|
if (it != null) {
|
||||||
|
|
|
@ -65,6 +65,8 @@ object IntegerTable {
|
||||||
const val START_ID_ACTIVITY = 0xA050
|
const val START_ID_ACTIVITY = 0xA050
|
||||||
/** Tasker AuxioService Start ID */
|
/** Tasker AuxioService Start ID */
|
||||||
const val START_ID_TASKER = 0xA051
|
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 */
|
||||||
|
@ -123,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 */
|
||||||
|
@ -139,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
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,6 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio
|
package org.oxycblt.auxio
|
||||||
|
|
||||||
import android.animation.ValueAnimator
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
|
@ -27,6 +26,7 @@ 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.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
|
||||||
|
@ -50,10 +50,8 @@ 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.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.Song
|
|
||||||
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
|
||||||
|
@ -70,6 +68,8 @@ import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -257,9 +257,9 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPreDraw(): Boolean {
|
override fun onPreDraw(): Boolean {
|
||||||
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
|
// This is where I shove literally all the UI logic that won't behave any callback
|
||||||
// sheets continually getting stuck. I need something with even more frequent updates,
|
// or "normal" method I've tried. Surely running this on every frame will actually cause
|
||||||
// or otherwise bottom sheets get stuck.
|
// 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
|
||||||
|
@ -367,6 +367,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -404,9 +408,6 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIndexerState(state: IndexingState?) {
|
private fun updateIndexerState(state: IndexingState?) {
|
||||||
// TODO: Make music loading experience a bit more pleasant
|
|
||||||
// 1. Loading placeholder for item lists
|
|
||||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
|
||||||
if (state is IndexingState.Completed && state.error == null) {
|
if (state is IndexingState.Completed && state.error == null) {
|
||||||
L.d("Received ok response")
|
L.d("Received ok response")
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
|
@ -512,8 +513,6 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var scrimAnimator: ValueAnimator? = null
|
|
||||||
|
|
||||||
private fun updateSpeedDial(open: Boolean) {
|
private fun updateSpeedDial(open: Boolean) {
|
||||||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||||
.invalidateEnabled(open)
|
.invalidateEnabled(open)
|
||||||
|
|
|
@ -22,6 +22,7 @@ import android.os.Bundle
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
|
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
|
||||||
|
@ -29,12 +30,9 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
||||||
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.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.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.PlaylistMessage
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
|
@ -44,6 +42,10 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,7 +117,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
binding.detailToolbarTitle.text = name
|
binding.detailToolbarTitle.text = name
|
||||||
binding.detailCover.bind(album)
|
binding.detailCover.bind(album)
|
||||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||||
binding.detailType.text = getString(album.releaseType.stringRes)
|
binding.detailType.text = album.releaseType.resolve(context)
|
||||||
binding.detailName.text = name
|
binding.detailName.text = name
|
||||||
// Artist name maps to the subhead text
|
// Artist name maps to the subhead text
|
||||||
binding.detailSubhead.apply {
|
binding.detailSubhead.apply {
|
||||||
|
@ -131,7 +133,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
// Date, song count, and duration map to the info text
|
// Date, song count, and duration map to the info text
|
||||||
binding.detailInfo.apply {
|
binding.detailInfo.apply {
|
||||||
// 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
|
||||||
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
|
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
|
||||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||||
val duration = album.durationMs.formatDurationMs(true)
|
val duration = album.durationMs.formatDurationMs(true)
|
||||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||||
|
@ -140,9 +142,15 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarPlay.setOnClickListener {
|
||||||
|
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
|
}
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarShuffle.setOnClickListener {
|
||||||
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
|
}
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
@ -291,6 +299,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
|
// 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.
|
// collapsed appbar), so we need to collapse the appbar if that's the case.
|
||||||
binding.detailAppbar.setExpanded(false)
|
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.
|
||||||
|
@ -316,4 +329,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,13 +29,9 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
||||||
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.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.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.PlaylistMessage
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.resolveNames
|
import org.oxycblt.auxio.music.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
|
@ -44,6 +40,11 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -163,9 +164,15 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarPlay.setOnClickListener {
|
||||||
|
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
|
}
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarShuffle.setOnClickListener {
|
||||||
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
|
}
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.PlainDivider
|
import org.oxycblt.auxio.list.PlainDivider
|
||||||
import org.oxycblt.auxio.list.PlainHeader
|
import org.oxycblt.auxio.list.PlainHeader
|
||||||
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.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
|
import org.oxycblt.musikr.Music
|
||||||
|
import org.oxycblt.musikr.MusicParent
|
||||||
|
|
||||||
abstract class DetailFragment<P : MusicParent, C : Music> :
|
abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||||
ListFragment<C, FragmentDetailBinding>(),
|
ListFragment<C, FragmentDetailBinding>(),
|
||||||
|
@ -123,6 +123,9 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||||
val detailContent = binding.detailToolbarContent
|
val detailContent = binding.detailToolbarContent
|
||||||
detailContent.alpha = inRatio
|
detailContent.alpha = inRatio
|
||||||
detailContent.translationY = spacingSmall * (1 - inRatio)
|
detailContent.translationY = spacingSmall * (1 - inRatio)
|
||||||
|
|
||||||
|
// Enable fast scrolling once fully collapsed
|
||||||
|
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun onOpenParentMenu()
|
abstract fun onOpenParentMenu()
|
||||||
|
|
|
@ -23,17 +23,17 @@ import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.ListSettings
|
import org.oxycblt.auxio.list.ListSettings
|
||||||
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.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
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.musikr.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.musikr.Artist
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
import org.oxycblt.musikr.Genre
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
interface DetailGenerator {
|
interface DetailGenerator {
|
||||||
|
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun album(uid: Music.UID): Detail<Album>? {
|
override fun album(uid: Music.UID): Detail<Album>? {
|
||||||
val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null
|
val album = musicRepository.library?.findAlbum(uid) ?: return null
|
||||||
val songs = listSettings.albumSongSort.songs(album.songs)
|
val songs = listSettings.albumSongSort.songs(album.songs)
|
||||||
val discs = songs.groupBy { it.disc }
|
val discs = songs.groupBy { it.disc }
|
||||||
val section =
|
val section =
|
||||||
|
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun artist(uid: Music.UID): Detail<Artist>? {
|
override fun artist(uid: Music.UID): Detail<Artist>? {
|
||||||
val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null
|
val artist = musicRepository.library?.findArtist(uid) ?: return null
|
||||||
val grouping =
|
val grouping =
|
||||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||||
// Remap the complicated ReleaseType data structure into detail sections
|
// Remap the complicated ReleaseType data structure into detail sections
|
||||||
|
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun genre(uid: Music.UID): Detail<Genre>? {
|
override fun genre(uid: Music.UID): Detail<Genre>? {
|
||||||
val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null
|
val genre = musicRepository.library?.findGenre(uid) ?: return null
|
||||||
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
|
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||||
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
|
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
|
||||||
return Detail(genre, listOf(artists, songs))
|
return Detail(genre, listOf(artists, songs))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun playlist(uid: Music.UID): Detail<Playlist>? {
|
override fun playlist(uid: Music.UID): Detail<Playlist>? {
|
||||||
val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null
|
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
|
||||||
if (playlist.songs.isNotEmpty()) {
|
if (playlist.songs.isNotEmpty()) {
|
||||||
val songs = DetailSection.Songs(playlist.songs)
|
val songs = DetailSection.Songs(playlist.songs)
|
||||||
return Detail(playlist, listOf(songs))
|
return Detail(playlist, listOf(songs))
|
||||||
|
|
|
@ -22,16 +22,14 @@ 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.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.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
|
@ -40,21 +38,20 @@ import org.oxycblt.auxio.list.PlainDivider
|
||||||
import org.oxycblt.auxio.list.PlainHeader
|
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.MusicParent
|
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
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.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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -69,11 +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
|
detailGeneratorFactory: DetailGenerator.Factory
|
||||||
) : ViewModel(), DetailGenerator.Invalidator {
|
) : 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.
|
||||||
*/
|
*/
|
||||||
|
@ -82,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
|
||||||
|
@ -121,15 +122,18 @@ 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
|
||||||
|
@ -145,15 +149,18 @@ 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
|
||||||
|
@ -169,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.
|
||||||
|
@ -308,14 +319,14 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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) {
|
||||||
L.d("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) {
|
||||||
L.w("Given song UID was invalid")
|
L.w("Given song UID was invalid")
|
||||||
}
|
}
|
||||||
|
@ -511,16 +522,32 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAudioInfo(song: Song) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
L.d("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)))
|
||||||
L.d("Updating audio info to $info")
|
}
|
||||||
_songAudioProperties.value = info
|
song.disc?.let {
|
||||||
|
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
|
||||||
|
}
|
||||||
|
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
|
||||||
|
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
|
||||||
|
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
|
||||||
|
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
|
||||||
|
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
|
||||||
|
add(
|
||||||
|
SongProperty(
|
||||||
|
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
|
||||||
|
song.replayGainAdjustment.track?.let {
|
||||||
|
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
|
||||||
|
}
|
||||||
|
song.replayGainAdjustment.album?.let {
|
||||||
|
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,13 +29,9 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
||||||
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.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.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.PlaylistMessage
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
@ -43,6 +39,11 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,9 +133,15 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarPlay.setOnClickListener {
|
||||||
|
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
|
}
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarShuffle.setOnClickListener {
|
||||||
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
|
}
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,13 +35,9 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
||||||
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.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.Playlist
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.PlaylistMessage
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.resolve
|
||||||
import org.oxycblt.auxio.music.external.M3U
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||||
|
@ -52,6 +48,11 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.showToast
|
import org.oxycblt.auxio.util.showToast
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -231,12 +232,24 @@ class PlaylistDetailFragment :
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarPlay.apply {
|
||||||
|
isEnabled = playable
|
||||||
|
setOnClickListener {
|
||||||
|
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.detailShuffleButton?.apply {
|
binding.detailShuffleButton?.apply {
|
||||||
isEnabled = playable
|
isEnabled = playable
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding.detailToolbarShuffle.apply {
|
||||||
|
isEnabled = playable
|
||||||
|
setOnClickListener {
|
||||||
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 @@ 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.playback.replaygain.formatDb
|
|
||||||
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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -71,74 +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?) {
|
||||||
if (song == null) {
|
|
||||||
L.d("No song to show, navigating away")
|
L.d("No song to show, navigating away")
|
||||||
|
if (song == null) {
|
||||||
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_path, song.path.resolve(context)))
|
|
||||||
info.resolvedMimeType.resolveName(context)?.let {
|
|
||||||
add(SongProperty(R.string.lbl_format, it))
|
|
||||||
}
|
|
||||||
add(
|
|
||||||
SongProperty(
|
|
||||||
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
|
|
||||||
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
|
|
||||||
info.bitrateKbps?.let {
|
|
||||||
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
|
|
||||||
}
|
|
||||||
info.sampleRateHz?.let {
|
|
||||||
add(
|
|
||||||
SongProperty(
|
|
||||||
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
|
|
||||||
}
|
|
||||||
song.replayGainAdjustment.track?.let {
|
|
||||||
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
|
|
||||||
}
|
|
||||||
song.replayGainAdjustment.album?.let {
|
|
||||||
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
UpdateInstructions.Replace(0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Music> T.zipName(context: Context): String {
|
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,12 +23,12 @@ 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.musikr.Library
|
||||||
|
import org.oxycblt.musikr.Music
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,9 +56,9 @@ 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)
|
||||||
L.d("Updated artist choices: ${_artistChoices.value}")
|
L.d("Updated artist choices: ${_artistChoices.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,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]. */
|
||||||
|
@ -115,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,9 +32,9 @@ 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.musikr.Artist
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -35,14 +35,14 @@ 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.music.info.resolveNumber
|
|
||||||
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.getAttrColorCompat
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
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.
|
||||||
|
@ -121,7 +121,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||||
*/
|
*/
|
||||||
fun bind(discHeader: DiscHeader) {
|
fun bind(discHeader: DiscHeader) {
|
||||||
val disc = discHeader.inner
|
val disc = discHeader.inner
|
||||||
binding.discNumber.text = disc.resolveNumber(binding.context)
|
binding.discNumber.text = disc.resolve(binding.context)
|
||||||
binding.discName.apply {
|
binding.discName.apply {
|
||||||
text = disc?.name
|
text = disc?.name
|
||||||
isGone = disc?.name == null
|
isGone = disc?.name == null
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -35,9 +35,9 @@ 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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -40,12 +40,13 @@ 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.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
import org.oxycblt.musikr.Playlist
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,8 +26,8 @@ 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.musikr.Album
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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.musikr.Artist
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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.musikr.Genre
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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.musikr.Playlist
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,10 @@ 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
|
||||||
|
@ -37,12 +37,10 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.google.android.material.transition.MaterialSharedAxis
|
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 java.lang.reflect.Method
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||||
|
@ -53,31 +51,27 @@ 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.Playlist
|
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.PlaylistMessage
|
import org.oxycblt.auxio.music.PlaylistMessage
|
||||||
import org.oxycblt.auxio.music.external.M3U
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
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.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.auxio.util.showToast
|
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -178,6 +172,7 @@ 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)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
|
@ -271,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,98 +297,49 @@ 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 -> {
|
|
||||||
L.d("Indexer is in indeterminate state")
|
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
|
||||||
if (error == null) {
|
|
||||||
L.d("Received ok response")
|
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
|
||||||
L.d("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 -> {
|
|
||||||
L.d("Showing permission prompt")
|
|
||||||
binding.homeIndexingStatus.setText(R.string.err_no_perms)
|
|
||||||
// Configure the action to act as a permission launcher.
|
|
||||||
binding.homeIndexingTry.apply {
|
|
||||||
text = context.getString(R.string.lbl_grant)
|
|
||||||
setOnClickListener {
|
|
||||||
requireNotNull(storagePermissionLauncher) {
|
|
||||||
"Permission launcher was not available"
|
|
||||||
}
|
|
||||||
.launch(PERMISSION_READ_AUDIO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.homeIndexingMore.visibility = View.GONE
|
|
||||||
}
|
|
||||||
is NoMusicException -> {
|
|
||||||
L.d("Showing no music error")
|
|
||||||
binding.homeIndexingStatus.setText(R.string.err_no_music)
|
|
||||||
// Configure the action to act as a reload trigger.
|
|
||||||
binding.homeIndexingTry.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
text = context.getString(R.string.lbl_retry)
|
|
||||||
setOnClickListener { musicModel.refresh() }
|
|
||||||
}
|
|
||||||
binding.homeIndexingMore.visibility = View.GONE
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
L.d("Showing generic error")
|
|
||||||
binding.homeIndexingStatus.setText(R.string.err_index_failed)
|
|
||||||
// Configure the action to act as a reload trigger.
|
|
||||||
binding.homeIndexingTry.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
text = context.getString(R.string.lbl_retry)
|
|
||||||
setOnClickListener { musicModel.rescan() }
|
|
||||||
}
|
|
||||||
binding.homeIndexingMore.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
setOnClickListener {
|
|
||||||
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
binding.homeIndexingStatus.setText(R.string.lng_indexing)
|
binding.homeIndexingError.isInvisible = state.error == null
|
||||||
when (progress) {
|
if (state.error != null) {
|
||||||
is IndexingProgress.Indeterminate -> {
|
binding.homeIndexingContainer.setOnClickListener {
|
||||||
// In a query/initialization state, show a generic loading status.
|
findNavController()
|
||||||
binding.homeIndexingProgress.isIndeterminate = true
|
.navigateSafe(HomeFragmentDirections.reportError(state.error))
|
||||||
}
|
}
|
||||||
is IndexingProgress.Songs -> {
|
} else {
|
||||||
// Actively loading songs, show the current progress.
|
binding.homeIndexingContainer.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is IndexingState.Indexing -> {
|
||||||
|
binding.homeIndexingContainer.isInvisible = false
|
||||||
binding.homeIndexingProgress.apply {
|
binding.homeIndexingProgress.apply {
|
||||||
|
isInvisible = false
|
||||||
|
when (state.progress) {
|
||||||
|
is IndexingProgress.Songs -> {
|
||||||
isIndeterminate = false
|
isIndeterminate = false
|
||||||
max = progress.total
|
progress = state.progress.loaded
|
||||||
this.progress = progress.current
|
max = state.progress.explored
|
||||||
}
|
}
|
||||||
|
is IndexingProgress.Indeterminate -> {
|
||||||
|
isIndeterminate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.homeIndexingError.isInvisible = true
|
||||||
|
}
|
||||||
|
null -> {
|
||||||
|
binding.homeIndexingContainer.isInvisible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -564,11 +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")
|
||||||
val FAB_HIDE_FROM_USER_FIELD: Method by
|
|
||||||
lazyReflectedMethod(
|
|
||||||
FloatingActionButton::class,
|
|
||||||
"hide",
|
|
||||||
FloatingActionButton.OnVisibilityChangedListener::class,
|
|
||||||
Boolean::class)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,13 +22,13 @@ import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.home.tabs.Tab
|
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.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.MusicRepository
|
||||||
import org.oxycblt.auxio.music.MusicType
|
import org.oxycblt.auxio.music.MusicType
|
||||||
import org.oxycblt.auxio.music.Playlist
|
import org.oxycblt.musikr.Album
|
||||||
import org.oxycblt.auxio.music.Song
|
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
interface HomeGenerator {
|
interface HomeGenerator {
|
||||||
|
@ -36,6 +36,8 @@ interface HomeGenerator {
|
||||||
|
|
||||||
fun release()
|
fun release()
|
||||||
|
|
||||||
|
fun empty(): Boolean
|
||||||
|
|
||||||
fun songs(): List<Song>
|
fun songs(): List<Song>
|
||||||
|
|
||||||
fun albums(): List<Album>
|
fun albums(): List<Album>
|
||||||
|
@ -49,6 +51,8 @@ interface HomeGenerator {
|
||||||
fun tabs(): List<MusicType>
|
fun tabs(): List<MusicType>
|
||||||
|
|
||||||
interface Invalidator {
|
interface Invalidator {
|
||||||
|
fun invalidateEmpty() {}
|
||||||
|
|
||||||
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
|
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
|
||||||
|
|
||||||
fun invalidateTabs()
|
fun invalidateTabs()
|
||||||
|
@ -119,8 +123,10 @@ private class HomeGeneratorImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
invalidator.invalidateEmpty()
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
|
||||||
|
val library = musicRepository.library
|
||||||
|
if (changes.deviceLibrary && library != null) {
|
||||||
L.d("Refreshing library")
|
L.d("Refreshing library")
|
||||||
// Get the each list of items in the library to use as our list data.
|
// Get the each list of items in the library to use as our list data.
|
||||||
// Applying the preferred sorting to them.
|
// Applying the preferred sorting to them.
|
||||||
|
@ -130,8 +136,7 @@ private class HomeGeneratorImpl(
|
||||||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
val userLibrary = musicRepository.userLibrary
|
if (changes.userLibrary && library != null) {
|
||||||
if (changes.userLibrary && userLibrary != null) {
|
|
||||||
L.d("Refreshing playlists")
|
L.d("Refreshing playlists")
|
||||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
||||||
}
|
}
|
||||||
|
@ -143,15 +148,16 @@ private class HomeGeneratorImpl(
|
||||||
homeSettings.unregisterListener(this)
|
homeSettings.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun empty() = musicRepository.library?.empty() ?: true
|
||||||
|
|
||||||
override fun songs() =
|
override fun songs() =
|
||||||
musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||||
|
|
||||||
override fun albums() =
|
override fun albums() =
|
||||||
musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
|
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
override fun artists() =
|
override fun artists() =
|
||||||
musicRepository.deviceLibrary?.let { deviceLibrary ->
|
musicRepository.library?.let { deviceLibrary ->
|
||||||
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
|
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
|
||||||
if (homeSettings.shouldHideCollaborators) {
|
if (homeSettings.shouldHideCollaborators) {
|
||||||
sorted.filter { it.explicitAlbums.isNotEmpty() }
|
sorted.filter { it.explicitAlbums.isNotEmpty() }
|
||||||
|
@ -161,11 +167,10 @@ private class HomeGeneratorImpl(
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
override fun genres() =
|
override fun genres() =
|
||||||
musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
|
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
override fun playlists() =
|
override fun playlists() =
|
||||||
musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
|
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
|
|
||||||
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||||
|
|
|
@ -27,16 +27,16 @@ 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.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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -120,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>
|
||||||
|
@ -159,6 +163,10 @@ 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 {
|
||||||
homeGenerator.attach()
|
homeGenerator.attach()
|
||||||
}
|
}
|
||||||
|
@ -168,6 +176,10 @@ constructor(
|
||||||
homeGenerator.release()
|
homeGenerator.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invalidateEmpty() {
|
||||||
|
_empty.value = homeGenerator.empty()
|
||||||
|
}
|
||||||
|
|
||||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||||
when (type) {
|
when (type) {
|
||||||
MusicType.SONGS -> {
|
MusicType.SONGS -> {
|
||||||
|
@ -263,6 +275,10 @@ constructor(
|
||||||
_isFastScrolling.value = isFastScrolling
|
_isFastScrolling.value = isFastScrolling
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun startChooseMusicLocations() {
|
||||||
|
_chooseMusicLocations.put(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
fun showSettings() {
|
fun showSettings() {
|
||||||
_showOuter.put(Outer.Settings)
|
_showOuter.put(Outer.Settings)
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,6 +190,8 @@ class ThemedSpeedDialView : SpeedDialView {
|
||||||
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
|
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
|
||||||
overlayLayout.setBackgroundColor(overlayColor)
|
overlayLayout.setBackgroundColor(overlayColor)
|
||||||
}
|
}
|
||||||
|
// Fix default margins added by library
|
||||||
|
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.withModulatedAlpha(
|
private fun Int.withModulatedAlpha(
|
||||||
|
@ -230,13 +232,24 @@ class ThemedSpeedDialView : SpeedDialView {
|
||||||
return super.addActionItem(actionItem, position, animate)?.apply {
|
return super.addActionItem(actionItem, position, animate)?.apply {
|
||||||
fab.apply {
|
fab.apply {
|
||||||
updateLayoutParams<MarginLayoutParams> {
|
updateLayoutParams<MarginLayoutParams> {
|
||||||
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
|
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
|
||||||
setMargins(horizontalMargin, 0, horizontalMargin, 0)
|
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
|
useCompatPadding = false
|
||||||
}
|
}
|
||||||
|
|
||||||
labelBackground.apply {
|
labelBackground.apply {
|
||||||
|
updateLayoutParams<MarginLayoutParams> {
|
||||||
|
if (position == actionItems.lastIndex) {
|
||||||
|
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||||
|
setMargins(0, 0, rightMargin, bottomMargin)
|
||||||
|
}
|
||||||
|
}
|
||||||
useCompatPadding = false
|
useCompatPadding = false
|
||||||
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
|
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
|
||||||
background =
|
background =
|
||||||
|
|
|
@ -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
|
||||||
|
@ -36,15 +38,16 @@ 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.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)
|
||||||
|
@ -99,10 +111,10 @@ class AlbumListFragment :
|
||||||
// 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,6 +21,8 @@ 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
|
||||||
|
@ -34,15 +36,16 @@ 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.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)
|
||||||
|
@ -94,7 +106,7 @@ class ArtistListFragment :
|
||||||
// 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,6 +21,8 @@ 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
|
||||||
|
@ -34,14 +36,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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)
|
||||||
|
@ -93,7 +105,7 @@ class GenreListFragment :
|
||||||
// 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,6 +21,8 @@ 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
|
||||||
|
@ -33,14 +35,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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)
|
||||||
|
@ -91,7 +105,7 @@ class PlaylistListFragment :
|
||||||
// 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,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
|
||||||
|
@ -35,14 +37,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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)
|
||||||
|
@ -98,23 +111,23 @@ class SongListFragment :
|
||||||
// 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,60 +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 homeTab = tabs[position]
|
|
||||||
val icon =
|
|
||||||
when (homeTab) {
|
|
||||||
MusicType.SONGS -> R.drawable.ic_song_24
|
|
||||||
MusicType.ALBUMS -> R.drawable.ic_album_24
|
|
||||||
MusicType.ARTISTS -> R.drawable.ic_artist_24
|
|
||||||
MusicType.GENRES -> R.drawable.ic_genre_24
|
|
||||||
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use expected sw* size thresholds when choosing a configuration.
|
|
||||||
when {
|
|
||||||
// On small screens, only display an icon.
|
|
||||||
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
|
|
||||||
// On large screens, display an icon and text.
|
|
||||||
width < 600 -> tab.setText(homeTab.nameRes)
|
|
||||||
// On medium-size screens, display text.
|
|
||||||
else -> tab.setIcon(icon).setText(homeTab.nameRes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.cover))
|
.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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,31 +37,35 @@ import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.Px
|
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 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.Cover
|
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
|
||||||
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
|
import org.oxycblt.auxio.image.coil.SquareCropTransformation
|
||||||
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
|
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.ui.MaterialFader
|
import org.oxycblt.auxio.ui.MaterialFader
|
||||||
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.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
import org.oxycblt.auxio.util.getDrawableCompat
|
import org.oxycblt.auxio.util.getDrawableCompat
|
||||||
|
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
|
||||||
|
@ -169,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -313,7 +317,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(song: Song) =
|
fun bind(song: Song) =
|
||||||
bindImpl(
|
bindImpl(
|
||||||
listOf(song.cover),
|
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)
|
||||||
|
|
||||||
|
@ -324,7 +328,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(album: Album) =
|
fun bind(album: Album) =
|
||||||
bindImpl(
|
bindImpl(
|
||||||
album.cover.all,
|
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)
|
||||||
|
|
||||||
|
@ -335,7 +339,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(artist: Artist) =
|
fun bind(artist: Artist) =
|
||||||
bindImpl(
|
bindImpl(
|
||||||
artist.cover.all,
|
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)
|
||||||
|
|
||||||
|
@ -346,7 +350,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(genre: Genre) =
|
fun bind(genre: Genre) =
|
||||||
bindImpl(
|
bindImpl(
|
||||||
genre.cover.all,
|
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)
|
||||||
|
|
||||||
|
@ -357,7 +361,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*/
|
*/
|
||||||
fun bind(playlist: Playlist) =
|
fun bind(playlist: Playlist) =
|
||||||
bindImpl(
|
bindImpl(
|
||||||
playlist.cover?.all ?: emptyList(),
|
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)
|
||||||
|
|
||||||
|
@ -369,13 +373,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
||||||
*/
|
*/
|
||||||
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
||||||
bindImpl(Cover.order(songs), desc, errorRes)
|
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
|
||||||
|
|
||||||
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
|
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
|
||||||
val request =
|
val request =
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(covers)
|
.data(cover)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
|
.error(
|
||||||
|
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||||
|
.asImage())
|
||||||
.target(image)
|
.target(image)
|
||||||
|
|
||||||
val cornersTransformation =
|
val cornersTransformation =
|
||||||
|
@ -404,7 +410,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
@Px val iconSize: Int?
|
@Px val iconSize: Int?
|
||||||
) : Drawable() {
|
) : Drawable() {
|
||||||
init {
|
init {
|
||||||
// Re-tint the drawable to use the analogous "on surfaceg" color for
|
// Re-tint the drawable to use the analogous "on surface" color for
|
||||||
// StyledImageView.
|
// StyledImageView.
|
||||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
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,6 +74,24 @@ 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) {
|
||||||
|
@ -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,
|
||||||
keyer: CoverKeyer,
|
coverKeyer: CoverKeyer,
|
||||||
factory: CoverFetcher.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(keyer)
|
add(coverFactory)
|
||||||
add(factory)
|
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)
|
||||||
|
|
|
@ -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,46 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 Auxio Project
|
|
||||||
* Components.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
|
||||||
|
|
||||||
import coil.ImageLoader
|
|
||||||
import coil.fetch.Fetcher
|
|
||||||
import coil.key.Keyer
|
|
||||||
import coil.request.Options
|
|
||||||
import coil.size.Size
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
|
|
||||||
override fun key(data: Collection<Cover>, options: Options) =
|
|
||||||
"${data.map { it.key }.hashCode()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
class CoverFetcher
|
|
||||||
private constructor(
|
|
||||||
private val covers: Collection<Cover>,
|
|
||||||
private val size: Size,
|
|
||||||
private val coverExtractor: CoverExtractor,
|
|
||||||
) : Fetcher {
|
|
||||||
override suspend fun fetch() = coverExtractor.extract(covers, size)
|
|
||||||
|
|
||||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
|
||||||
Fetcher.Factory<Collection<Cover>> {
|
|
||||||
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
|
||||||
CoverFetcher(data, options.size, coverExtractor)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* Cover.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
|
|
||||||
sealed interface Cover {
|
|
||||||
val key: String
|
|
||||||
val mediaStoreCoverUri: Uri
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
|
|
||||||
*/
|
|
||||||
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
|
|
||||||
Cover {
|
|
||||||
override val mediaStoreCoverUri = songCoverUri
|
|
||||||
override val key = perceptualHash
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We couldn't find any embedded cover art ourselves, but the android system might have some
|
|
||||||
* through a cover.jpg file or something similar.
|
|
||||||
*/
|
|
||||||
data class External(val albumCoverUri: Uri) : Cover {
|
|
||||||
override val mediaStoreCoverUri = albumCoverUri
|
|
||||||
override val key = albumCoverUri.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
|
|
||||||
|
|
||||||
fun order(songs: Collection<Song>) =
|
|
||||||
FALLBACK_SORT.songs(songs)
|
|
||||||
.map { it.cover }
|
|
||||||
.groupBy { it.key }
|
|
||||||
.entries
|
|
||||||
.sortedByDescending { it.value.size }
|
|
||||||
.map { it.value.first() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ParentCover(val single: Cover, val all: List<Cover>) {
|
|
||||||
companion object {
|
|
||||||
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
|
|
||||||
|
|
||||||
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,253 +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.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.media.MediaMetadataRetriever
|
|
||||||
import android.util.Size as AndroidSize
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.MediaMetadata
|
|
||||||
import androidx.media3.common.Metadata
|
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
|
||||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
|
||||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
|
||||||
import coil.decode.DataSource
|
|
||||||
import coil.decode.ImageSource
|
|
||||||
import coil.fetch.DrawableResult
|
|
||||||
import coil.fetch.FetchResult
|
|
||||||
import coil.fetch.SourceResult
|
|
||||||
import coil.size.Dimension
|
|
||||||
import coil.size.Size
|
|
||||||
import coil.size.pxOrElse
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okio.buffer
|
|
||||||
import okio.source
|
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides functionality for extracting album cover information. Meant for internal use only.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class CoverExtractor
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val imageSettings: ImageSettings,
|
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
|
|
||||||
*
|
|
||||||
* @param covers The [Cover]s to load.
|
|
||||||
* @param size The [Size] of the image to load.
|
|
||||||
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
|
|
||||||
* will be returned of a mosaic composed of the first four loaded album covers. Otherwise, a
|
|
||||||
* [SourceResult] of one album cover will be returned.
|
|
||||||
*/
|
|
||||||
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
|
|
||||||
val streams = mutableListOf<InputStream>()
|
|
||||||
for (cover in covers) {
|
|
||||||
openCoverInputStream(cover)?.let(streams::add)
|
|
||||||
// We don't immediately check for mosaic feasibility from album count alone, as that
|
|
||||||
// does not factor in InputStreams failing to load. Instead, only check once we
|
|
||||||
// definitely have image data to use.
|
|
||||||
if (streams.size == 4) {
|
|
||||||
// Make sure we free the InputStreams once we've transformed them into a mosaic.
|
|
||||||
return createMosaic(streams, size).also {
|
|
||||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
|
||||||
val first = streams.firstOrNull() ?: return null
|
|
||||||
|
|
||||||
// All but the first stream will be unused, free their resources
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
for (i in 1 until streams.size) {
|
|
||||||
streams[i].close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SourceResult(
|
|
||||||
source = ImageSource(first.source().buffer(), context),
|
|
||||||
mimeType = null,
|
|
||||||
dataSource = DataSource.DISK)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
|
|
||||||
var stream: ByteArrayInputStream? = null
|
|
||||||
|
|
||||||
for (i in 0 until metadata.length()) {
|
|
||||||
// We can only extract pictures from two tags with this method, ID3v2's APIC or
|
|
||||||
// Vorbis picture comments.
|
|
||||||
val pic: ByteArray?
|
|
||||||
val type: Int
|
|
||||||
|
|
||||||
when (val entry = metadata.get(i)) {
|
|
||||||
is ApicFrame -> {
|
|
||||||
pic = entry.pictureData
|
|
||||||
type = entry.pictureType
|
|
||||||
}
|
|
||||||
is PictureFrame -> {
|
|
||||||
pic = entry.pictureData
|
|
||||||
type = entry.pictureType
|
|
||||||
}
|
|
||||||
else -> continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
|
||||||
stream = ByteArrayInputStream(pic)
|
|
||||||
break
|
|
||||||
} else if (stream == null) {
|
|
||||||
stream = ByteArrayInputStream(pic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun openCoverInputStream(cover: Cover) =
|
|
||||||
try {
|
|
||||||
when (cover) {
|
|
||||||
is Cover.Embedded ->
|
|
||||||
when (imageSettings.coverMode) {
|
|
||||||
CoverMode.OFF -> null
|
|
||||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
|
|
||||||
CoverMode.QUALITY -> extractQualityCover(cover)
|
|
||||||
}
|
|
||||||
is Cover.External -> {
|
|
||||||
extractMediaStoreCover(cover)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Unable to extract album cover due to an error: $e")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractQualityCover(cover: Cover.Embedded) =
|
|
||||||
extractExoplayerCover(cover)
|
|
||||||
?: extractAospMetadataCover(cover)
|
|
||||||
?: extractMediaStoreCover(cover)
|
|
||||||
|
|
||||||
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
|
|
||||||
MediaMetadataRetriever().run {
|
|
||||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
|
||||||
// so it's probably fine not to wrap it.rmt
|
|
||||||
setDataSource(context, cover.songUri)
|
|
||||||
|
|
||||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
|
||||||
// ByteArray of the cover without any compression artifacts.
|
|
||||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
|
||||||
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
|
|
||||||
val tracks =
|
|
||||||
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
|
|
||||||
.asDeferred()
|
|
||||||
.await()
|
|
||||||
|
|
||||||
// The metadata extraction process of ExoPlayer results in a dump of all metadata
|
|
||||||
// it found, which must be iterated through.
|
|
||||||
val metadata = tracks[0].getFormat(0).metadata
|
|
||||||
|
|
||||||
if (metadata == null || metadata.length() == 0) {
|
|
||||||
// No (parsable) metadata. This is also expected.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return findCoverDataInMetadata(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("Recycle")
|
|
||||||
private suspend fun extractMediaStoreCover(cover: Cover) =
|
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
// Coil will recycle this InputStream, so we don't need to worry about it.
|
|
||||||
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
|
||||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
|
||||||
// Use whatever size coil gives us to create the mosaic.
|
|
||||||
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
|
||||||
val mosaicFrameSize =
|
|
||||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
|
||||||
|
|
||||||
val mosaicBitmap =
|
|
||||||
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(mosaicBitmap)
|
|
||||||
|
|
||||||
var x = 0
|
|
||||||
var y = 0
|
|
||||||
|
|
||||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
|
||||||
// and place it on a corner of the canvas.
|
|
||||||
for (stream in streams) {
|
|
||||||
if (y == mosaicSize.height) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crop the bitmap down to a square so it leaves no empty space
|
|
||||||
// TODO: Work around this
|
|
||||||
val bitmap =
|
|
||||||
SquareCropTransformation.INSTANCE.transform(
|
|
||||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
|
||||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
|
||||||
|
|
||||||
x += bitmap.width
|
|
||||||
if (x == mosaicSize.width) {
|
|
||||||
x = 0
|
|
||||||
y += bitmap.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// It's way easier to map this into a drawable then try to serialize it into an
|
|
||||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
|
||||||
// load low-res mosaics into high-res ImageViews.
|
|
||||||
return DrawableResult(
|
|
||||||
drawable = mosaicBitmap.toDrawable(context.resources),
|
|
||||||
isSampled = true,
|
|
||||||
dataSource = DataSource.DISK)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Dimension.mosaicSize(): Int {
|
|
||||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
|
||||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
|
||||||
val size = pxOrElse { 512 }
|
|
||||||
return if (size.mod(2) > 0) size + 1 else size
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* DHash.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.ColorMatrix
|
|
||||||
import android.graphics.ColorMatrixColorFilter
|
|
||||||
import android.graphics.Paint
|
|
||||||
import java.math.BigInteger
|
|
||||||
|
|
||||||
@Suppress("UNUSED")
|
|
||||||
fun Bitmap.dHash(hashSize: Int = 16): String {
|
|
||||||
// Step 1: Resize the bitmap to a fixed size
|
|
||||||
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
|
||||||
|
|
||||||
// Step 2: Convert the bitmap to grayscale
|
|
||||||
val grayBitmap =
|
|
||||||
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(grayBitmap)
|
|
||||||
val paint = Paint()
|
|
||||||
val colorMatrix = ColorMatrix()
|
|
||||||
colorMatrix.setSaturation(0f)
|
|
||||||
val filter = ColorMatrixColorFilter(colorMatrix)
|
|
||||||
paint.colorFilter = filter
|
|
||||||
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
|
|
||||||
|
|
||||||
// Step 3: Compute the difference between adjacent pixels
|
|
||||||
var hash = BigInteger.valueOf(0)
|
|
||||||
val one = BigInteger.valueOf(1)
|
|
||||||
for (y in 0 until hashSize) {
|
|
||||||
for (x in 0 until hashSize) {
|
|
||||||
val pixel1 = grayBitmap.getPixel(x, y)
|
|
||||||
val pixel2 = grayBitmap.getPixel(x + 1, y)
|
|
||||||
val diff = Color.red(pixel1) - Color.red(pixel2)
|
|
||||||
if (diff > 0) {
|
|
||||||
hash = hash.or(one.shl(y * hashSize + x))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash.toString(16)
|
|
||||||
}
|
|
|
@ -22,9 +22,9 @@ 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 : Item
|
interface Header
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A "header" used for delimiting groups of data.
|
* A "header" used for delimiting groups of data.
|
||||||
|
@ -44,7 +44,7 @@ interface PlainHeader : Header {
|
||||||
*/
|
*/
|
||||||
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||||
|
|
||||||
interface Divider<T> : Item {
|
interface Divider<T> {
|
||||||
val anchor: T?
|
val anchor: T?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -25,17 +25,17 @@ 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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +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.music.Music
|
import org.oxycblt.musikr.Music
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,9 +23,9 @@ 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.musikr.MusicParent
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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,21 +19,37 @@
|
||||||
package org.oxycblt.auxio.list.recycler
|
package org.oxycblt.auxio.list.recycler
|
||||||
|
|
||||||
import android.animation.Animator
|
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.HapticFeedbackConstants
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
|
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.updatePaddingRelative
|
||||||
|
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.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.ui.MaterialFadingSlider
|
||||||
import org.oxycblt.auxio.ui.MaterialSlider
|
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.inflater
|
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
|
||||||
|
@ -62,33 +78,70 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* - Added drag listener
|
* - Added drag listener
|
||||||
* - Added documentation
|
* - Added documentation
|
||||||
* - Completely new design
|
* - 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 thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
|
||||||
private val slider = MaterialSlider(context, thumbSize)
|
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
||||||
|
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
||||||
private var thumbAnimator: Animator? = null
|
private var thumbAnimator: Animator? = null
|
||||||
|
|
||||||
|
@SuppressLint("InflateParams")
|
||||||
private val thumbView =
|
private val thumbView =
|
||||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val popupView =
|
||||||
|
MaterialTextView(context).apply {
|
||||||
|
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
|
||||||
|
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
|
|
||||||
|
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
|
||||||
|
setTextColor(
|
||||||
|
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
|
||||||
|
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
includeFontPadding = false
|
||||||
|
|
||||||
|
elevation =
|
||||||
|
context
|
||||||
|
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
|
||||||
|
.toFloat()
|
||||||
|
background = context.getDrawableCompat(R.drawable.ui_popup)
|
||||||
|
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
|
||||||
|
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
|
||||||
|
updatePaddingRelative(start = paddingStart, end = paddingEnd)
|
||||||
|
layoutParams =
|
||||||
|
FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
.apply {
|
||||||
|
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val popupSlider =
|
||||||
|
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
|
||||||
|
jumpOut(popupView)
|
||||||
|
}
|
||||||
|
private var popupAnimator: Animator? = null
|
||||||
|
private var showingPopup = false
|
||||||
|
|
||||||
// Touch
|
// Touch
|
||||||
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||||
|
@ -99,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) {
|
||||||
|
@ -116,7 +187,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
if (field) {
|
if (field) {
|
||||||
removeCallbacks(hideThumbRunnable)
|
removeCallbacks(hideThumbRunnable)
|
||||||
showScrollbar()
|
showScrollbar()
|
||||||
|
showPopup()
|
||||||
} else {
|
} else {
|
||||||
|
hidePopup()
|
||||||
postAutoHideScrollbar()
|
postAutoHideScrollbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,6 +201,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
|
|
||||||
init {
|
init {
|
||||||
overlay.add(thumbView)
|
overlay.add(thumbView)
|
||||||
|
overlay.add(popupView)
|
||||||
|
|
||||||
addItemDecoration(
|
addItemDecoration(
|
||||||
object : ItemDecoration() {
|
object : ItemDecoration() {
|
||||||
|
@ -156,26 +230,96 @@ 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
|
||||||
thumbView.measure(
|
thumbView.measure(
|
||||||
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
|
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
|
||||||
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
|
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
|
||||||
val thumbTop = thumbPadding.top + thumbOffset
|
val thumbTop = thumbPadding.top + thumbOffset
|
||||||
val thumbLeft =
|
val thumbLeft =
|
||||||
if (isRtl) {
|
if (isRtl) {
|
||||||
thumbPadding.left
|
thumbPadding.left
|
||||||
} else {
|
} else {
|
||||||
width - thumbPadding.right - thumbSize
|
width - thumbPadding.right - thumbWidth
|
||||||
}
|
}
|
||||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
|
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||||
|
|
||||||
|
popupView.layoutDirection = layoutDirection
|
||||||
|
val child = getChildAt(0)
|
||||||
|
val firstAdapterPos =
|
||||||
|
if (child != null) {
|
||||||
|
layoutManager?.getPosition(child) ?: NO_POSITION
|
||||||
|
} else {
|
||||||
|
NO_POSITION
|
||||||
|
}
|
||||||
|
|
||||||
|
val popupText: String
|
||||||
|
val provider = popupProvider
|
||||||
|
if (firstAdapterPos != NO_POSITION && provider != null) {
|
||||||
|
popupView.isInvisible = false
|
||||||
|
// Get the popup text. If there is none, we default to "?".
|
||||||
|
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
||||||
|
} else {
|
||||||
|
// No valid position or provider, do not show the popup.
|
||||||
|
popupView.isInvisible = false
|
||||||
|
popupText = ""
|
||||||
|
}
|
||||||
|
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
||||||
|
|
||||||
|
if (popupView.text != popupText) {
|
||||||
|
popupView.text = popupText
|
||||||
|
|
||||||
|
val widthMeasureSpec =
|
||||||
|
ViewGroup.getChildMeasureSpec(
|
||||||
|
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||||
|
thumbPadding.left +
|
||||||
|
thumbPadding.right +
|
||||||
|
thumbWidth +
|
||||||
|
popupLayoutParams.leftMargin +
|
||||||
|
popupLayoutParams.rightMargin,
|
||||||
|
popupLayoutParams.width)
|
||||||
|
|
||||||
|
val heightMeasureSpec =
|
||||||
|
ViewGroup.getChildMeasureSpec(
|
||||||
|
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||||
|
thumbPadding.top +
|
||||||
|
thumbPadding.bottom +
|
||||||
|
popupLayoutParams.topMargin +
|
||||||
|
popupLayoutParams.bottomMargin,
|
||||||
|
popupLayoutParams.height)
|
||||||
|
|
||||||
|
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
if (showingPopup) {
|
||||||
|
doPopupVibration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val popupWidth = popupView.measuredWidth
|
||||||
|
val popupHeight = popupView.measuredHeight
|
||||||
|
val popupLeft =
|
||||||
|
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
|
||||||
|
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
|
||||||
|
} else {
|
||||||
|
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
val popupAnchorY = popupHeight / 2
|
||||||
|
val thumbAnchorY = thumbView.height / 2
|
||||||
|
|
||||||
|
val popupTop =
|
||||||
|
(thumbTop + thumbAnchorY - popupAnchorY)
|
||||||
|
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
|
||||||
|
.coerceAtMost(
|
||||||
|
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
|
||||||
|
|
||||||
|
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
@ -193,11 +337,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
return insets
|
return insets
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateScrollbarState() {
|
private fun updateThumbState() {
|
||||||
// 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]
|
||||||
|
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
|
||||||
val offsetY = computeVerticalScrollOffset()
|
val offsetY = computeVerticalScrollOffset()
|
||||||
if (computeVerticalScrollRange() < height || childCount == 0) {
|
if (computeVerticalScrollRange() < height || isEmpty()) {
|
||||||
|
fastScrollingPossible = false
|
||||||
|
hideThumb()
|
||||||
|
hidePopup()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val extentY = computeVerticalScrollExtent()
|
val extentY = computeVerticalScrollExtent()
|
||||||
|
@ -206,6 +354,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
@ -219,8 +371,9 @@ 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 if (eventX > thumbView.right - thumbSize / 4) {
|
} else if (eventX > thumbView.right - thumbWidth / 4) {
|
||||||
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
dragStartThumbOffset =
|
||||||
|
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||||
scrollToThumbOffset(dragStartThumbOffset)
|
scrollToThumbOffset(dragStartThumbOffset)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
|
@ -238,7 +391,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
dragStartThumbOffset = thumbOffset
|
dragStartThumbOffset = thumbOffset
|
||||||
} else {
|
} else {
|
||||||
dragStartY = eventY
|
dragStartY = eventY
|
||||||
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
dragStartThumbOffset =
|
||||||
|
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||||
scrollToThumbOffset(dragStartThumbOffset)
|
scrollToThumbOffset(dragStartThumbOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,30 +436,65 @@ 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
|
||||||
thumbAnimator?.cancel()
|
thumbAnimator?.cancel()
|
||||||
thumbAnimator = slider.slideIn(thumbView).also { it.start() }
|
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideScrollbar() {
|
private fun hideThumb() {
|
||||||
if (!showingThumb) {
|
if (!showingThumb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showingThumb = false
|
showingThumb = false
|
||||||
thumbAnimator?.cancel()
|
thumbAnimator?.cancel()
|
||||||
thumbAnimator = slider.slideOut(thumbView).also { it.start() }
|
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPopup() {
|
||||||
|
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (showingPopup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showingPopup = true
|
||||||
|
popupAnimator?.cancel()
|
||||||
|
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hidePopup() {
|
||||||
|
if (!showingPopup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showingPopup = false
|
||||||
|
popupAnimator?.cancel()
|
||||||
|
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doPopupVibration() {
|
||||||
|
performHapticFeedback(
|
||||||
|
if (Build.VERSION.SDK_INT >= 27) {
|
||||||
|
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||||
|
} else {
|
||||||
|
HapticFeedbackConstants.KEYBOARD_TAP
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LAYOUT STATE ---
|
// --- LAYOUT STATE ---
|
||||||
|
|
||||||
private val thumbOffsetRange: Int
|
private val thumbOffsetRange: Int
|
||||||
get() {
|
get() {
|
||||||
return height - thumbPadding.top - thumbPadding.bottom - thumbSize
|
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
|
|
|
@ -92,7 +92,6 @@ 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) {
|
||||||
L.d("Lifting ViewHolder")
|
L.d("Lifting ViewHolder")
|
||||||
|
|
||||||
|
|
|
@ -32,16 +32,17 @@ import org.oxycblt.auxio.list.PlainDivider
|
||||||
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.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Playlist
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.areNamesTheSame
|
import org.oxycblt.auxio.music.areNamesTheSame
|
||||||
|
import org.oxycblt.auxio.music.resolve
|
||||||
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.getPlural
|
import org.oxycblt.auxio.util.getPlural
|
||||||
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.Genre
|
||||||
|
import org.oxycblt.musikr.Playlist
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
||||||
|
|
|
@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
|
||||||
|
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.musikr.Album
|
||||||
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.Playlist
|
import org.oxycblt.musikr.Playlist
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.musikr.Song
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sorting method.
|
* A sorting method.
|
||||||
|
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
||||||
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
|
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
|
||||||
songs.sortBy { it.name }
|
songs.sortBy { it.name }
|
||||||
when (direction) {
|
when (direction) {
|
||||||
Direction.ASCENDING -> songs.sortBy { it.dateAdded }
|
Direction.ASCENDING -> songs.sortBy { it.addedMs }
|
||||||
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
|
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
|
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
|
||||||
albums.sortBy { it.name }
|
albums.sortBy { it.name }
|
||||||
when (direction) {
|
when (direction) {
|
||||||
Direction.ASCENDING -> albums.sortBy { it.dateAdded }
|
Direction.ASCENDING -> albums.sortBy { it.addedMs }
|
||||||
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
|
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,87 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* Indexing.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
|
|
||||||
/** Version-aware permission identifier for reading audio files. */
|
|
||||||
val PERMISSION_READ_AUDIO =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
android.Manifest.permission.READ_MEDIA_AUDIO
|
|
||||||
} else {
|
|
||||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the current state of the music loader.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
sealed interface IndexingState {
|
|
||||||
/**
|
|
||||||
* Music loading is on-going.
|
|
||||||
*
|
|
||||||
* @param progress The current progress of the music loading.
|
|
||||||
*/
|
|
||||||
data class Indexing(val progress: IndexingProgress) : IndexingState
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Music loading has completed.
|
|
||||||
*
|
|
||||||
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
|
||||||
* will be null.
|
|
||||||
*/
|
|
||||||
data class Completed(val error: Exception?) : IndexingState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the current progress of music loading.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
sealed interface IndexingProgress {
|
|
||||||
/** Other work is being done that does not have a defined progress. */
|
|
||||||
data object Indeterminate : IndexingProgress
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Songs are currently being loaded.
|
|
||||||
*
|
|
||||||
* @param current The current amount of songs loaded.
|
|
||||||
* @param total The projected total amount of songs.
|
|
||||||
*/
|
|
||||||
data class Songs(val current: Int, val total: Int) : IndexingProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class NoAudioPermissionException : Exception() {
|
|
||||||
override val message = "Storage permissions are required to load music"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when no music was found.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class NoMusicException : Exception() {
|
|
||||||
override val message = "No music was found on the device"
|
|
||||||
}
|
|
|
@ -19,31 +19,30 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import androidx.core.content.ContextCompat
|
import java.util.UUID
|
||||||
import java.util.LinkedList
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.music.cache.CacheRepository
|
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
|
||||||
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
|
import org.oxycblt.musikr.IndexingProgress
|
||||||
import org.oxycblt.auxio.music.info.Name
|
import org.oxycblt.musikr.Interpretation
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
import org.oxycblt.musikr.Library
|
||||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
import org.oxycblt.musikr.Music
|
||||||
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
import org.oxycblt.musikr.Musikr
|
||||||
import org.oxycblt.auxio.music.user.UserLibrary
|
import org.oxycblt.musikr.MutableLibrary
|
||||||
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
import org.oxycblt.musikr.Playlist
|
||||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
import org.oxycblt.musikr.Song
|
||||||
|
import org.oxycblt.musikr.Storage
|
||||||
|
import org.oxycblt.musikr.cache.MutableCache
|
||||||
|
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||||
|
import org.oxycblt.musikr.tag.interpret.Naming
|
||||||
|
import org.oxycblt.musikr.tag.interpret.Separators
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -58,10 +57,9 @@ import timber.log.Timber as L
|
||||||
* configurations
|
* configurations
|
||||||
*/
|
*/
|
||||||
interface MusicRepository {
|
interface MusicRepository {
|
||||||
/** The current music information found on the device. */
|
/** The current library */
|
||||||
val deviceLibrary: DeviceLibrary?
|
val library: Library?
|
||||||
/** The current user-defined music information. */
|
|
||||||
val userLibrary: UserLibrary?
|
|
||||||
/** The current state of music loading. Null if no load has occurred yet. */
|
/** The current state of music loading. Null if no load has occurred yet. */
|
||||||
val indexingState: IndexingState?
|
val indexingState: IndexingState?
|
||||||
|
|
||||||
|
@ -175,7 +173,7 @@ interface MusicRepository {
|
||||||
* @param withCache Whether to load with the music cache or not.
|
* @param withCache Whether to load with the music cache or not.
|
||||||
* @return The top-level music loading [Job] started.
|
* @return The top-level music loading [Job] started.
|
||||||
*/
|
*/
|
||||||
fun index(worker: IndexingWorker, withCache: Boolean): Job
|
suspend fun index(worker: IndexingWorker, withCache: Boolean)
|
||||||
|
|
||||||
/** A listener for changes to the stored music information. */
|
/** A listener for changes to the stored music information. */
|
||||||
interface UpdateListener {
|
interface UpdateListener {
|
||||||
|
@ -190,8 +188,8 @@ interface MusicRepository {
|
||||||
/**
|
/**
|
||||||
* Flags indicating which kinds of music information changed.
|
* Flags indicating which kinds of music information changed.
|
||||||
*
|
*
|
||||||
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
|
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
|
||||||
* @param userLibrary Whether the current [Playlist]s have changed.
|
* @param userLibrary Whether the current playlists have changed.
|
||||||
*/
|
*/
|
||||||
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
||||||
|
|
||||||
|
@ -203,12 +201,6 @@ interface MusicRepository {
|
||||||
|
|
||||||
/** A persistent worker that can load music in the background. */
|
/** A persistent worker that can load music in the background. */
|
||||||
interface IndexingWorker {
|
interface IndexingWorker {
|
||||||
/** A [Context] required to read device storage */
|
|
||||||
val workerContext: Context
|
|
||||||
|
|
||||||
/** The [CoroutineScope] to perform coroutine music loading work on. */
|
|
||||||
val scope: CoroutineScope
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request that the music loading process ([index]) should be started. Any prior loads
|
* Request that the music loading process ([index]) should be started. Any prior loads
|
||||||
* should be canceled.
|
* should be canceled.
|
||||||
|
@ -219,22 +211,42 @@ interface MusicRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current state of the music loader.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
sealed interface IndexingState {
|
||||||
|
/**
|
||||||
|
* Music loading is on-going.
|
||||||
|
*
|
||||||
|
* @param progress The current progress of the music loading.
|
||||||
|
*/
|
||||||
|
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Music loading has completed.
|
||||||
|
*
|
||||||
|
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||||
|
* will be null.
|
||||||
|
*/
|
||||||
|
data class Completed(val error: Exception?) : IndexingState
|
||||||
|
}
|
||||||
|
|
||||||
class MusicRepositoryImpl
|
class MusicRepositoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val cacheRepository: CacheRepository,
|
@ApplicationContext private val context: Context,
|
||||||
private val mediaStoreExtractor: MediaStoreExtractor,
|
private val cache: MutableCache,
|
||||||
private val tagExtractor: TagExtractor,
|
private val storedPlaylists: StoredPlaylists,
|
||||||
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
private val settingCovers: SettingCovers,
|
||||||
private val userLibraryFactory: UserLibrary.Factory,
|
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : MusicRepository {
|
) : MusicRepository {
|
||||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||||
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
|
@Volatile private var indexingWorker: IndexingWorker? = null
|
||||||
|
|
||||||
@Volatile override var deviceLibrary: DeviceLibrary? = null
|
@Volatile override var library: MutableLibrary? = null
|
||||||
@Volatile override var userLibrary: MutableUserLibrary? = null
|
|
||||||
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
||||||
@Volatile private var currentIndexingState: IndexingState? = null
|
@Volatile private var currentIndexingState: IndexingState? = null
|
||||||
override val indexingState: IndexingState?
|
override val indexingState: IndexingState?
|
||||||
|
@ -271,7 +283,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
override fun registerWorker(worker: IndexingWorker) {
|
||||||
if (indexingWorker != null) {
|
if (indexingWorker != null) {
|
||||||
L.w("Worker is already registered")
|
L.w("Worker is already registered")
|
||||||
return
|
return
|
||||||
|
@ -281,7 +293,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
override fun unregisterWorker(worker: IndexingWorker) {
|
||||||
if (indexingWorker !== worker) {
|
if (indexingWorker !== worker) {
|
||||||
L.w("Given worker did not match current worker")
|
L.w("Given worker did not match current worker")
|
||||||
return
|
return
|
||||||
|
@ -293,41 +305,51 @@ constructor(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun find(uid: Music.UID) =
|
override fun find(uid: Music.UID) =
|
||||||
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
(library?.run {
|
||||||
?: userLibrary?.findPlaylist(uid))
|
findSong(uid)
|
||||||
|
?: findAlbum(uid)
|
||||||
|
?: findArtist(uid)
|
||||||
|
?: findGenre(uid)
|
||||||
|
?: findPlaylist(uid)
|
||||||
|
})
|
||||||
|
|
||||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val library = synchronized(this) { library ?: return }
|
||||||
L.d("Creating playlist $name with ${songs.size} songs")
|
L.d("Creating playlist $name with ${songs.size} songs")
|
||||||
userLibrary.createPlaylist(name, songs)
|
val newLibrary = library.createPlaylist(name, songs)
|
||||||
|
synchronized(this) { this.library = newLibrary }
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val library = synchronized(this) { library ?: return }
|
||||||
L.d("Renaming $playlist to $name")
|
L.d("Renaming $playlist to $name")
|
||||||
userLibrary.renamePlaylist(playlist, name)
|
val newLibrary = library.renamePlaylist(playlist, name)
|
||||||
|
synchronized(this) { this.library = newLibrary }
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val library = synchronized(this) { library ?: return }
|
||||||
L.d("Deleting $playlist")
|
L.d("Deleting $playlist")
|
||||||
userLibrary.deletePlaylist(playlist)
|
val newLibrary = library.deletePlaylist(playlist)
|
||||||
|
synchronized(this) { this.library = newLibrary }
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val library = synchronized(this) { library ?: return }
|
||||||
L.d("Adding ${songs.size} songs to $playlist")
|
L.d("Adding ${songs.size} songs to $playlist")
|
||||||
userLibrary.addToPlaylist(playlist, songs)
|
val newLibrary = library.addToPlaylist(playlist, songs)
|
||||||
|
synchronized(this) { this.library = newLibrary }
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
val library = synchronized(this) { library ?: return }
|
||||||
L.d("Rewriting $playlist with ${songs.size} songs")
|
L.d("Rewriting $playlist with ${songs.size} songs")
|
||||||
userLibrary.rewritePlaylist(playlist, songs)
|
val newLibrary = library.rewritePlaylist(playlist, songs)
|
||||||
|
synchronized(this) { this.library = newLibrary }
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -337,241 +359,53 @@ constructor(
|
||||||
indexingWorker?.requestIndex(withCache)
|
indexingWorker?.requestIndex(withCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
|
||||||
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
|
L.d("Begin index [cache=$withCache]")
|
||||||
|
|
||||||
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
|
||||||
try {
|
try {
|
||||||
indexImpl(context, scope, withCache)
|
indexImpl(withCache)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// Got cancelled, propagate upwards to top-level co-routine.
|
// Got cancelled, propagate upwards to top-level co-routine.
|
||||||
L.d("Loading routine was cancelled")
|
L.d("Loading routine was cancelled")
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Music loading process failed due to something we have not handled.
|
// Music loading process failed due to something we have not handled.
|
||||||
// TODO: Still want to display this error eventually
|
|
||||||
L.e("Music indexing failed")
|
L.e("Music indexing failed")
|
||||||
L.e(e.stackTraceToString())
|
L.e(e.stackTraceToString())
|
||||||
emitIndexingCompletion(e)
|
emitIndexingCompletion(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
private suspend fun indexImpl(withCache: Boolean) {
|
||||||
// TODO: Find a way to break up this monster of a method, preferably as another class.
|
|
||||||
|
|
||||||
val start = System.currentTimeMillis()
|
|
||||||
// Make sure we have permissions before going forward. Theoretically this would be better
|
|
||||||
// done at the UI level, but that intertwines logic and display too much.
|
|
||||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_DENIED) {
|
|
||||||
L.e("Permissions were not granted")
|
|
||||||
throw NoAudioPermissionException()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtain configuration information
|
// Obtain configuration information
|
||||||
val constraints =
|
|
||||||
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
|
|
||||||
val separators = Separators.from(musicSettings.separators)
|
val separators = Separators.from(musicSettings.separators)
|
||||||
val nameFactory =
|
val nameFactory =
|
||||||
if (musicSettings.intelligentSorting) {
|
if (musicSettings.intelligentSorting) {
|
||||||
Name.Known.IntelligentFactory
|
Naming.intelligent()
|
||||||
} else {
|
} else {
|
||||||
Name.Known.SimpleFactory
|
Naming.simple()
|
||||||
}
|
}
|
||||||
|
val locations = musicSettings.musicLocations
|
||||||
|
val withHidden = musicSettings.withHidden
|
||||||
|
|
||||||
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
|
val currentRevision = musicSettings.revision
|
||||||
// to figure out what songs are (probably) on the device, and the latter will be needed
|
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
||||||
// for discovery (described later). These have no shared state, so they are done in
|
val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
|
||||||
// parallel.
|
val covers = settingCovers.mutate(context, newRevision)
|
||||||
L.d("Starting MediaStore query")
|
val storage = Storage(cache, covers, storedPlaylists)
|
||||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
val interpretation = Interpretation(nameFactory, separators, withHidden)
|
||||||
|
val result =
|
||||||
val mediaStoreQueryJob =
|
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
||||||
scope.async {
|
// Music loading completed, update the revision right now so we re-use this work
|
||||||
val query =
|
// later.
|
||||||
try {
|
musicSettings.revision = newRevision
|
||||||
mediaStoreExtractor.query(constraints)
|
// Deliver the library to the rest of the app
|
||||||
} catch (e: Exception) {
|
// This will more or less block until all required item translation and
|
||||||
// Normally, errors in an async call immediately bubble up to the Looper
|
// cleanup finishes.
|
||||||
// and crash the app. Thus, we have to wrap any error into a Result
|
emitLibrary(result.library)
|
||||||
// and then manually forward it to the try block that indexImpl is
|
// Clean up old data that is now impossible for the app to be using.
|
||||||
// called from.
|
result.cleanup()
|
||||||
return@async Result.failure(e)
|
// Finish up loading.
|
||||||
}
|
|
||||||
Result.success(query)
|
|
||||||
}
|
|
||||||
// Since this main thread is a co-routine, we can do operations in parallel in a way
|
|
||||||
// identical to calling async.
|
|
||||||
val cache =
|
|
||||||
if (withCache) {
|
|
||||||
L.d("Reading cache")
|
|
||||||
cacheRepository.readCache()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
L.d("Awaiting MediaStore query")
|
|
||||||
val query = mediaStoreQueryJob.await().getOrThrow()
|
|
||||||
|
|
||||||
// We now have all the information required to start the "discovery" process. This
|
|
||||||
// is the point at which Auxio starts scanning each file given from MediaStore and
|
|
||||||
// transforming it into a music library. MediaStore normally
|
|
||||||
L.d("Starting discovery")
|
|
||||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
|
|
||||||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
|
|
||||||
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
|
|
||||||
|
|
||||||
// MediaStoreExtractor discovers all music on the device, and forwards them to either
|
|
||||||
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
|
|
||||||
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
|
|
||||||
L.d("Starting MediaStore discovery")
|
|
||||||
val mediaStoreJob =
|
|
||||||
scope.async {
|
|
||||||
try {
|
|
||||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// To prevent a deadlock, we want to close the channel with an exception
|
|
||||||
// to cascade to and cancel all other routines before finally bubbling up
|
|
||||||
// to the main extractor loop.
|
|
||||||
L.e("MediaStore extraction failed: $e")
|
|
||||||
incompleteSongs.close(
|
|
||||||
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
|
|
||||||
return@async
|
|
||||||
}
|
|
||||||
incompleteSongs.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
|
|
||||||
// metadata for them, and then forwards it to DeviceLibrary.
|
|
||||||
L.d("Starting tag extraction")
|
|
||||||
val tagJob =
|
|
||||||
scope.async {
|
|
||||||
try {
|
|
||||||
tagExtractor.consume(incompleteSongs, completeSongs)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Tag extraction failed: $e")
|
|
||||||
completeSongs.close(
|
|
||||||
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
|
|
||||||
return@async
|
|
||||||
}
|
|
||||||
completeSongs.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeviceLibrary constructs music parent instances as song information is provided,
|
|
||||||
// and then forwards them to the primary loading loop.
|
|
||||||
L.d("Starting DeviceLibrary creation")
|
|
||||||
val deviceLibraryJob =
|
|
||||||
scope.async(Dispatchers.Default) {
|
|
||||||
val deviceLibrary =
|
|
||||||
try {
|
|
||||||
deviceLibraryFactory.create(
|
|
||||||
completeSongs, processedSongs, separators, nameFactory)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("DeviceLibrary creation failed: $e")
|
|
||||||
processedSongs.close(
|
|
||||||
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
|
|
||||||
return@async Result.failure(e)
|
|
||||||
}
|
|
||||||
processedSongs.close()
|
|
||||||
Result.success(deviceLibrary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We could keep track of a total here, but we also need to collate this RawSong information
|
|
||||||
// for when we write the cache later on in the finalization step.
|
|
||||||
val rawSongs = LinkedList<RawSong>()
|
|
||||||
// Use a longer timeout so that dependent components can timeout and throw errors that
|
|
||||||
// provide more context than if we timed out here.
|
|
||||||
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
|
|
||||||
rawSongs.add(it)
|
|
||||||
// Since discovery takes up the bulk of the music loading process, we switch to
|
|
||||||
// indicating a defined amount of loaded songs in comparison to the projected amount
|
|
||||||
// of songs that were queried.
|
|
||||||
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
|
||||||
}
|
|
||||||
|
|
||||||
withTimeout(DEFAULT_TIMEOUT) {
|
|
||||||
mediaStoreJob.await()
|
|
||||||
tagJob.await()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliberately done after the involved initialization step to make it less likely
|
|
||||||
// that the short-circuit occurs so quickly as to break the UI.
|
|
||||||
// TODO: Do not error, instead just wipe the entire library.
|
|
||||||
if (rawSongs.isEmpty()) {
|
|
||||||
L.e("Music library was empty")
|
|
||||||
throw NoMusicException()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now that the library is effectively loaded, we can start the finalization step, which
|
|
||||||
// involves writing new cache information and creating more music data that is derived
|
|
||||||
// from the library (e.g playlists)
|
|
||||||
L.d("Discovered ${rawSongs.size} songs, starting finalization")
|
|
||||||
|
|
||||||
// We have no idea how long the cache will take, and the playlist construction
|
|
||||||
// will be too fast to indicate, so switch back to an indeterminate state.
|
|
||||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
|
||||||
|
|
||||||
// The UserLibrary job is split into a query and construction step, a la MediaStore.
|
|
||||||
// This way, we can start working on playlists even as DeviceLibrary might still be
|
|
||||||
// working on parent information.
|
|
||||||
L.d("Starting UserLibrary query")
|
|
||||||
val userLibraryQueryJob =
|
|
||||||
scope.async {
|
|
||||||
val rawPlaylists =
|
|
||||||
try {
|
|
||||||
userLibraryFactory.query()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return@async Result.failure(e)
|
|
||||||
}
|
|
||||||
Result.success(rawPlaylists)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cache might not exist, or we might have encountered a song not present in it.
|
|
||||||
// Both situations require us to rewrite the cache in bulk. This is also done parallel
|
|
||||||
// since the playlist read will probably take some time.
|
|
||||||
// TODO: Read/write from the cache incrementally instead of in bulk?
|
|
||||||
if (cache == null || cache.invalidated) {
|
|
||||||
L.d("Writing cache [why=${cache?.invalidated}]")
|
|
||||||
cacheRepository.writeCache(rawSongs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create UserLibrary once we finally get the required components for it.
|
|
||||||
L.d("Awaiting UserLibrary query")
|
|
||||||
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
|
|
||||||
L.d("Awaiting DeviceLibrary creation")
|
|
||||||
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
|
||||||
L.d("Starting UserLibrary creation")
|
|
||||||
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
|
|
||||||
|
|
||||||
// Loading process is functionally done, indicate such
|
|
||||||
L.d(
|
|
||||||
"Successfully indexed music library [device=$deviceLibrary " +
|
|
||||||
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
|
|
||||||
emitIndexingCompletion(null)
|
emitIndexingCompletion(null)
|
||||||
|
|
||||||
val deviceLibraryChanged: Boolean
|
|
||||||
val userLibraryChanged: Boolean
|
|
||||||
// We want to make sure that all reads and writes are synchronized due to the sheer
|
|
||||||
// amount of consumers of MusicRepository.
|
|
||||||
// TODO: Would Atomics not be a better fit here?
|
|
||||||
synchronized(this) {
|
|
||||||
// It's possible that this reload might have changed nothing, so make sure that
|
|
||||||
// hasn't happened before dispatching a change to all consumers.
|
|
||||||
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
|
|
||||||
userLibraryChanged = this.userLibrary != userLibrary
|
|
||||||
if (!deviceLibraryChanged && !userLibraryChanged) {
|
|
||||||
L.d("Library has not changed, skipping update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deviceLibrary = deviceLibrary
|
|
||||||
this.userLibrary = userLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
|
||||||
// so switch to it.
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
|
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
|
||||||
|
@ -584,6 +418,39 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
|
||||||
|
val deviceLibraryChanged: Boolean
|
||||||
|
val userLibraryChanged: Boolean
|
||||||
|
// We want to make sure that all reads and writes are synchronized due to the sheer
|
||||||
|
// amount of consumers of MusicRepository.
|
||||||
|
synchronized(this) {
|
||||||
|
// It's possible that this reload might have changed nothing, so make sure that
|
||||||
|
// hasn't happened before dispatching a change to all consumers.
|
||||||
|
|
||||||
|
// This is an old compat shim back when device library and user library were different
|
||||||
|
// thinks. For the sake of avoiding drastic changes, it sticks around.
|
||||||
|
// TODO: Remove this once you start work on kindred.
|
||||||
|
deviceLibraryChanged =
|
||||||
|
this.library?.songs != newLibrary.songs ||
|
||||||
|
this.library?.albums != newLibrary.albums ||
|
||||||
|
this.library?.artists != newLibrary.artists ||
|
||||||
|
this.library?.genres != newLibrary.genres
|
||||||
|
userLibraryChanged = this.library?.playlists != newLibrary.playlists
|
||||||
|
if (!deviceLibraryChanged && !userLibraryChanged) {
|
||||||
|
L.d("Library has not changed, skipping update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.library = newLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
||||||
|
// so switch to it.
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitIndexingCompletion(error: Exception?) {
|
private suspend fun emitIndexingCompletion(error: Exception?) {
|
||||||
yield()
|
yield()
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
|
|
|
@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.music.dirs.MusicDirectories
|
|
||||||
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
|
import org.oxycblt.musikr.fs.MusicLocation
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -34,10 +34,14 @@ import timber.log.Timber as L
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface MusicSettings : Settings<MusicSettings.Listener> {
|
interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
/** The configuration on how to handle particular directories in the music library. */
|
/** The current library revision. */
|
||||||
var musicDirs: MusicDirectories
|
var revision: UUID?
|
||||||
|
/** The locations of music to load. */
|
||||||
|
var musicLocations: List<MusicLocation>
|
||||||
/** Whether to exclude non-music audio files from the music library. */
|
/** Whether to exclude non-music audio files from the music library. */
|
||||||
val excludeNonMusic: Boolean
|
val excludeNonMusic: Boolean
|
||||||
|
/** Whether to ignore hidden files and directories during music loading. */
|
||||||
|
val withHidden: Boolean
|
||||||
/** Whether to be actively watching for changes in the music library. */
|
/** Whether to be actively watching for changes in the music library. */
|
||||||
val shouldBeObserving: Boolean
|
val shouldBeObserving: Boolean
|
||||||
/** A [String] of characters representing the desired characters to denote multi-value tags. */
|
/** A [String] of characters representing the desired characters to denote multi-value tags. */
|
||||||
|
@ -46,6 +50,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
val intelligentSorting: Boolean
|
val intelligentSorting: Boolean
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
|
/** Called when the current music locations changed. */
|
||||||
|
fun onMusicLocationsChanged() {}
|
||||||
/** Called when a setting controlling how music is loaded has changed. */
|
/** Called when a setting controlling how music is loaded has changed. */
|
||||||
fun onIndexingSettingChanged() {}
|
fun onIndexingSettingChanged() {}
|
||||||
/** Called when the [shouldBeObserving] configuration has changed. */
|
/** Called when the [shouldBeObserving] configuration has changed. */
|
||||||
|
@ -53,35 +59,45 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MusicSettingsImpl
|
class MusicSettingsImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||||
@Inject
|
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||||
constructor(
|
|
||||||
@ApplicationContext context: Context,
|
override var revision: UUID?
|
||||||
private val documentPathFactory: DocumentPathFactory
|
get() =
|
||||||
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
sharedPreferences
|
||||||
override var musicDirs: MusicDirectories
|
.getString(getString(R.string.set_key_library_revision), null)
|
||||||
|
?.let(UUID::fromString)
|
||||||
|
set(value) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString(getString(R.string.set_key_library_revision), value.toString())
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var musicLocations: List<MusicLocation>
|
||||||
get() {
|
get() {
|
||||||
val dirs =
|
val locations =
|
||||||
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
sharedPreferences.getString(getString(R.string.set_key_music_locations), null)
|
||||||
?: emptySet())
|
?: return emptyList()
|
||||||
.mapNotNull(documentPathFactory::fromDocumentId)
|
return MusicLocation.existing(context, locations)
|
||||||
return MusicDirectories(
|
|
||||||
dirs,
|
|
||||||
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
|
||||||
}
|
}
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putStringSet(
|
putString(
|
||||||
getString(R.string.set_key_music_dirs),
|
getString(R.string.set_key_music_locations), MusicLocation.toString(value))
|
||||||
value.dirs.map(documentPathFactory::toDocumentId).toSet())
|
commit()
|
||||||
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
|
// Sometimes changing this setting just won't actually trigger the listener.
|
||||||
apply()
|
// Only this one. No idea why.
|
||||||
|
listener?.onMusicLocationsChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val excludeNonMusic: Boolean
|
override val excludeNonMusic: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
||||||
|
|
||||||
|
override val withHidden: Boolean
|
||||||
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false)
|
||||||
|
|
||||||
override val shouldBeObserving: Boolean
|
override val shouldBeObserving: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
||||||
|
|
||||||
|
@ -103,11 +119,14 @@ constructor(
|
||||||
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
||||||
// (just need to manipulate data)
|
// (just need to manipulate data)
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_exclude_non_music),
|
getString(R.string.set_key_music_locations) -> {
|
||||||
getString(R.string.set_key_music_dirs),
|
L.d("Dispatching music locations change")
|
||||||
getString(R.string.set_key_music_dirs_include),
|
listener.onMusicLocationsChanged()
|
||||||
|
}
|
||||||
getString(R.string.set_key_separators),
|
getString(R.string.set_key_separators),
|
||||||
getString(R.string.set_key_auto_sort_names) -> {
|
getString(R.string.set_key_auto_sort_names),
|
||||||
|
getString(R.string.set_key_with_hidden),
|
||||||
|
getString(R.string.set_key_exclude_non_music) -> {
|
||||||
L.d("Dispatching indexing setting change for $key")
|
L.d("Dispatching indexing setting change for $key")
|
||||||
listener.onIndexingSettingChanged()
|
listener.onIndexingSettingChanged()
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,15 +27,10 @@ import org.oxycblt.auxio.R
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
enum class MusicType {
|
enum class MusicType {
|
||||||
/** @see Song */
|
|
||||||
SONGS,
|
SONGS,
|
||||||
/** @see Album */
|
|
||||||
ALBUMS,
|
ALBUMS,
|
||||||
/** @see Artist */
|
|
||||||
ARTISTS,
|
ARTISTS,
|
||||||
/** @see Genre */
|
|
||||||
GENRES,
|
GENRES,
|
||||||
/** @see Playlist */
|
|
||||||
PLAYLISTS;
|
PLAYLISTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
173
app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt
Normal file
173
app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* MusicUtil.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import kotlin.math.max
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.util.concatLocalized
|
||||||
|
import org.oxycblt.musikr.Music
|
||||||
|
import org.oxycblt.musikr.fs.Format
|
||||||
|
import org.oxycblt.musikr.tag.Date
|
||||||
|
import org.oxycblt.musikr.tag.Disc
|
||||||
|
import org.oxycblt.musikr.tag.Name
|
||||||
|
import org.oxycblt.musikr.tag.Placeholder
|
||||||
|
import org.oxycblt.musikr.tag.ReleaseType
|
||||||
|
import org.oxycblt.musikr.tag.ReleaseType.Refinement
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
fun Name.resolve(context: Context) =
|
||||||
|
when (this) {
|
||||||
|
is Name.Known -> raw
|
||||||
|
is Name.Unknown ->
|
||||||
|
when (placeholder) {
|
||||||
|
Placeholder.ALBUM -> context.getString(R.string.def_album)
|
||||||
|
Placeholder.ARTIST -> context.getString(R.string.def_artist)
|
||||||
|
Placeholder.GENRE -> context.getString(R.string.def_genre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
|
||||||
|
* localized manner.
|
||||||
|
*
|
||||||
|
* @param context [Context] required
|
||||||
|
* @return A concatenated string.
|
||||||
|
*/
|
||||||
|
fun <T : Music> List<T>.resolveNames(context: Context) =
|
||||||
|
concatLocalized(context) { it.name.resolve(context) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
|
||||||
|
* information of an item must be compared without a context.
|
||||||
|
*
|
||||||
|
* @param other The list of items to compare to.
|
||||||
|
* @return True if they are the same (by [Music.name]), false otherwise.
|
||||||
|
*/
|
||||||
|
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
|
||||||
|
for (i in 0 until max(size, other.size)) {
|
||||||
|
val a = getOrNull(i) ?: return false
|
||||||
|
val b = other.getOrNull(i) ?: return false
|
||||||
|
if (a.name != b.name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve this instance into a human-readable date.
|
||||||
|
*
|
||||||
|
* @param context [Context] required to get human-readable names.
|
||||||
|
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan 2020")
|
||||||
|
* will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will be
|
||||||
|
* properly localized.
|
||||||
|
*/
|
||||||
|
fun Date.resolve(context: Context) =
|
||||||
|
// Unable to create fine-grained date, just format as a year.
|
||||||
|
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
|
||||||
|
|
||||||
|
private fun Date.resolveFineGrained(): String? {
|
||||||
|
// We can't directly load a date with our own
|
||||||
|
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
||||||
|
format.applyPattern("yyyy-MM")
|
||||||
|
val date =
|
||||||
|
try {
|
||||||
|
format.parse("$year-$month")
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
Timber.e("Unable to parse fine-grained date: $e")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformat as a readable month and year
|
||||||
|
format.applyPattern("MMM yyyy")
|
||||||
|
return format.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Disc?.resolve(context: Context) =
|
||||||
|
this?.run { context.getString(R.string.fmt_disc_no, number) }
|
||||||
|
?: context.getString(R.string.def_disc)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve this instance into a human-readable date range.
|
||||||
|
*
|
||||||
|
* @param context [Context] required to get human-readable names.
|
||||||
|
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
|
||||||
|
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
|
||||||
|
* formatted name of the minimum [Date] will be returned.
|
||||||
|
*/
|
||||||
|
fun Date.Range.resolve(context: Context) =
|
||||||
|
if (min != max) {
|
||||||
|
context.getString(R.string.fmt_date_range, min.resolve(context), max.resolve(context))
|
||||||
|
} else {
|
||||||
|
min.resolve(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ReleaseType.resolve(context: Context) =
|
||||||
|
when (this) {
|
||||||
|
is ReleaseType.Album ->
|
||||||
|
when (refinement) {
|
||||||
|
null -> context.getString(R.string.lbl_album)
|
||||||
|
Refinement.LIVE -> context.getString(R.string.lbl_album_live)
|
||||||
|
Refinement.REMIX -> context.getString(R.string.lbl_album_remix)
|
||||||
|
}
|
||||||
|
is ReleaseType.EP ->
|
||||||
|
when (refinement) {
|
||||||
|
null -> context.getString(R.string.lbl_ep)
|
||||||
|
Refinement.LIVE -> context.getString(R.string.lbl_ep_live)
|
||||||
|
Refinement.REMIX -> context.getString(R.string.lbl_ep_remix)
|
||||||
|
}
|
||||||
|
is ReleaseType.Single ->
|
||||||
|
when (refinement) {
|
||||||
|
null -> context.getString(R.string.lbl_single)
|
||||||
|
Refinement.LIVE -> context.getString(R.string.lbl_single_live)
|
||||||
|
Refinement.REMIX -> context.getString(R.string.lbl_single_remix)
|
||||||
|
}
|
||||||
|
is ReleaseType.Compilation ->
|
||||||
|
when (refinement) {
|
||||||
|
null -> context.getString(R.string.lbl_compilation)
|
||||||
|
Refinement.LIVE -> context.getString(R.string.lbl_compilation_live)
|
||||||
|
Refinement.REMIX -> context.getString(R.string.lbl_compilation_remix)
|
||||||
|
}
|
||||||
|
is ReleaseType.Soundtrack -> context.getString(R.string.lbl_soundtrack)
|
||||||
|
is ReleaseType.Mix -> context.getString(R.string.lbl_mix)
|
||||||
|
is ReleaseType.Mixtape -> context.getString(R.string.lbl_mixtape)
|
||||||
|
is ReleaseType.Demo -> context.getString(R.string.lbl_demo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Format.resolve(context: Context): String =
|
||||||
|
when (this) {
|
||||||
|
is Format.MPEG3 -> context.getString(R.string.cdc_mp3)
|
||||||
|
is Format.MPEG4 ->
|
||||||
|
containing?.let { context.getString(R.string.cnt_mp4, it.resolve(context)) }
|
||||||
|
?: context.getString(R.string.cdc_mp4)
|
||||||
|
is Format.AAC -> context.getString(R.string.cdc_aac)
|
||||||
|
is Format.ALAC -> context.getString(R.string.cdc_alac)
|
||||||
|
is Format.Ogg ->
|
||||||
|
containing?.let { context.getString(R.string.cnt_ogg, it.resolve(context)) }
|
||||||
|
?: context.getString(R.string.cdc_ogg)
|
||||||
|
is Format.Opus -> context.getString(R.string.cdc_opus)
|
||||||
|
is Format.Vorbis -> context.getString(R.string.cdc_vorbis)
|
||||||
|
is Format.FLAC -> context.getString(R.string.cdc_flac)
|
||||||
|
is Format.Wav -> context.getString(R.string.cdc_wav)
|
||||||
|
is Format.Unknown -> extension ?: context.getString(R.string.cdc_unknown)
|
||||||
|
}
|
|
@ -18,10 +18,12 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -29,10 +31,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.ListSettings
|
import org.oxycblt.auxio.list.ListSettings
|
||||||
import org.oxycblt.auxio.music.external.ExportConfig
|
|
||||||
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
|
|
||||||
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.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.playlist.ExportConfig
|
||||||
|
import org.oxycblt.musikr.playlist.ExternalPlaylistManager
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,10 +51,11 @@ import timber.log.Timber as L
|
||||||
class MusicViewModel
|
class MusicViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository
|
||||||
private val externalPlaylistManager: ExternalPlaylistManager
|
|
||||||
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
||||||
|
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
|
||||||
|
|
||||||
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
||||||
|
|
||||||
|
@ -85,14 +93,14 @@ constructor(
|
||||||
|
|
||||||
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
|
||||||
_statistics.value =
|
_statistics.value =
|
||||||
Statistics(
|
Statistics(
|
||||||
deviceLibrary.songs.size,
|
library.songs.size,
|
||||||
deviceLibrary.albums.size,
|
library.albums.size,
|
||||||
deviceLibrary.artists.size,
|
library.artists.size,
|
||||||
deviceLibrary.genres.size,
|
library.genres.size,
|
||||||
deviceLibrary.songs.sumOf { it.durationMs })
|
library.songs.sumOf { it.durationMs })
|
||||||
L.d("Updated statistics: ${_statistics.value}")
|
L.d("Updated statistics: ${_statistics.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,10 +170,10 @@ constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
val library = musicRepository.library ?: return@launch
|
||||||
val songs =
|
val songs =
|
||||||
importedPlaylist.paths.mapNotNull {
|
importedPlaylist.paths.mapNotNull {
|
||||||
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
|
it.firstNotNullOfOrNull(library::findSongByPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (songs.isEmpty()) {
|
if (songs.isEmpty()) {
|
||||||
|
|
|
@ -1,187 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* CacheDatabase.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.cache
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import androidx.room.TypeConverter
|
|
||||||
import androidx.room.TypeConverters
|
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
|
||||||
import org.oxycblt.auxio.music.info.Date
|
|
||||||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
|
||||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
|
||||||
|
|
||||||
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
|
|
||||||
abstract class CacheDatabase : RoomDatabase() {
|
|
||||||
abstract fun cachedSongsDao(): CachedSongsDao
|
|
||||||
}
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface CachedSongsDao {
|
|
||||||
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
|
|
||||||
|
|
||||||
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
|
|
||||||
|
|
||||||
@Insert suspend fun insertSongs(songs: List<CachedSong>)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@TypeConverters(CachedSong.Converters::class)
|
|
||||||
data class CachedSong(
|
|
||||||
/**
|
|
||||||
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly
|
|
||||||
* unstable and should only be used for accessing the audio file.
|
|
||||||
*/
|
|
||||||
@PrimaryKey var mediaStoreId: Long,
|
|
||||||
/** @see RawSong.dateAdded */
|
|
||||||
var dateAdded: Long,
|
|
||||||
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
|
|
||||||
var dateModified: Long,
|
|
||||||
/** @see RawSong.size */
|
|
||||||
var size: Long? = null,
|
|
||||||
/** @see RawSong */
|
|
||||||
var durationMs: Long,
|
|
||||||
/** @see RawSong.replayGainTrackAdjustment */
|
|
||||||
val replayGainTrackAdjustment: Float? = null,
|
|
||||||
/** @see RawSong.replayGainAlbumAdjustment */
|
|
||||||
val replayGainAlbumAdjustment: Float? = null,
|
|
||||||
/** @see RawSong.musicBrainzId */
|
|
||||||
var musicBrainzId: String? = null,
|
|
||||||
/** @see RawSong.name */
|
|
||||||
var name: String,
|
|
||||||
/** @see RawSong.sortName */
|
|
||||||
var sortName: String? = null,
|
|
||||||
/** @see RawSong.track */
|
|
||||||
var track: Int? = null,
|
|
||||||
/** @see RawSong.name */
|
|
||||||
var disc: Int? = null,
|
|
||||||
/** @See RawSong.subtitle */
|
|
||||||
var subtitle: String? = null,
|
|
||||||
/** @see RawSong.date */
|
|
||||||
var date: Date? = null,
|
|
||||||
/** @see RawSong.coverPerceptualHash */
|
|
||||||
var coverPerceptualHash: String? = null,
|
|
||||||
/** @see RawSong.albumMusicBrainzId */
|
|
||||||
var albumMusicBrainzId: String? = null,
|
|
||||||
/** @see RawSong.albumName */
|
|
||||||
var albumName: String,
|
|
||||||
/** @see RawSong.albumSortName */
|
|
||||||
var albumSortName: String? = null,
|
|
||||||
/** @see RawSong.releaseTypes */
|
|
||||||
var releaseTypes: List<String> = listOf(),
|
|
||||||
/** @see RawSong.artistMusicBrainzIds */
|
|
||||||
var artistMusicBrainzIds: List<String> = listOf(),
|
|
||||||
/** @see RawSong.artistNames */
|
|
||||||
var artistNames: List<String> = listOf(),
|
|
||||||
/** @see RawSong.artistSortNames */
|
|
||||||
var artistSortNames: List<String> = listOf(),
|
|
||||||
/** @see RawSong.albumArtistMusicBrainzIds */
|
|
||||||
var albumArtistMusicBrainzIds: List<String> = listOf(),
|
|
||||||
/** @see RawSong.albumArtistNames */
|
|
||||||
var albumArtistNames: List<String> = listOf(),
|
|
||||||
/** @see RawSong.albumArtistSortNames */
|
|
||||||
var albumArtistSortNames: List<String> = listOf(),
|
|
||||||
/** @see RawSong.genreNames */
|
|
||||||
var genreNames: List<String> = listOf()
|
|
||||||
) {
|
|
||||||
fun copyToRaw(rawSong: RawSong) {
|
|
||||||
rawSong.musicBrainzId = musicBrainzId
|
|
||||||
rawSong.name = name
|
|
||||||
rawSong.sortName = sortName
|
|
||||||
|
|
||||||
rawSong.size = size
|
|
||||||
rawSong.durationMs = durationMs
|
|
||||||
|
|
||||||
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
|
|
||||||
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
|
|
||||||
|
|
||||||
rawSong.track = track
|
|
||||||
rawSong.disc = disc
|
|
||||||
rawSong.subtitle = subtitle
|
|
||||||
rawSong.date = date
|
|
||||||
|
|
||||||
rawSong.coverPerceptualHash = coverPerceptualHash
|
|
||||||
|
|
||||||
rawSong.albumMusicBrainzId = albumMusicBrainzId
|
|
||||||
rawSong.albumName = albumName
|
|
||||||
rawSong.albumSortName = albumSortName
|
|
||||||
rawSong.releaseTypes = releaseTypes
|
|
||||||
|
|
||||||
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
|
|
||||||
rawSong.artistNames = artistNames
|
|
||||||
rawSong.artistSortNames = artistSortNames
|
|
||||||
|
|
||||||
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
|
|
||||||
rawSong.albumArtistNames = albumArtistNames
|
|
||||||
rawSong.albumArtistSortNames = albumArtistSortNames
|
|
||||||
|
|
||||||
rawSong.genreNames = genreNames
|
|
||||||
}
|
|
||||||
|
|
||||||
object Converters {
|
|
||||||
@TypeConverter
|
|
||||||
fun fromMultiValue(values: List<String>) =
|
|
||||||
values.joinToString(";") { it.replace(";", "\\;") }
|
|
||||||
|
|
||||||
@TypeConverter
|
|
||||||
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
|
||||||
|
|
||||||
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
|
||||||
|
|
||||||
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromRaw(rawSong: RawSong) =
|
|
||||||
CachedSong(
|
|
||||||
mediaStoreId =
|
|
||||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
|
|
||||||
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
|
|
||||||
dateModified =
|
|
||||||
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
|
|
||||||
musicBrainzId = rawSong.musicBrainzId,
|
|
||||||
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
|
|
||||||
sortName = rawSong.sortName,
|
|
||||||
size = rawSong.size,
|
|
||||||
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
|
|
||||||
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
|
|
||||||
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
|
|
||||||
track = rawSong.track,
|
|
||||||
disc = rawSong.disc,
|
|
||||||
subtitle = rawSong.subtitle,
|
|
||||||
date = rawSong.date,
|
|
||||||
coverPerceptualHash = rawSong.coverPerceptualHash,
|
|
||||||
albumMusicBrainzId = rawSong.albumMusicBrainzId,
|
|
||||||
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
|
|
||||||
albumSortName = rawSong.albumSortName,
|
|
||||||
releaseTypes = rawSong.releaseTypes,
|
|
||||||
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
|
|
||||||
artistNames = rawSong.artistNames,
|
|
||||||
artistSortNames = rawSong.artistSortNames,
|
|
||||||
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
|
|
||||||
albumArtistNames = rawSong.albumArtistNames,
|
|
||||||
albumArtistSortNames = rawSong.albumArtistSortNames,
|
|
||||||
genreNames = rawSong.genreNames)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2022 Auxio Project
|
|
||||||
* CacheRepository.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.cache
|
|
||||||
|
|
||||||
import javax.inject.Inject
|
|
||||||
import org.oxycblt.auxio.music.device.RawSong
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A repository allowing access to cached metadata obtained in prior music loading operations.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
interface CacheRepository {
|
|
||||||
/**
|
|
||||||
* Read the current [Cache], if it exists.
|
|
||||||
*
|
|
||||||
* @return The stored [Cache], or null if it could not be obtained.
|
|
||||||
*/
|
|
||||||
suspend fun readCache(): Cache?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
|
|
||||||
*
|
|
||||||
* @param rawSongs The [rawSongs] to write to the cache.
|
|
||||||
*/
|
|
||||||
suspend fun writeCache(rawSongs: List<RawSong>)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
|
|
||||||
CacheRepository {
|
|
||||||
override suspend fun readCache(): Cache? =
|
|
||||||
try {
|
|
||||||
// Faster to load the whole database into memory than do a query on each
|
|
||||||
// populate call.
|
|
||||||
val songs = cachedSongsDao.readSongs()
|
|
||||||
L.d("Successfully read ${songs.size} songs from cache")
|
|
||||||
CacheImpl(songs)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Unable to load cache database.")
|
|
||||||
L.e(e.stackTraceToString())
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun writeCache(rawSongs: List<RawSong>) {
|
|
||||||
try {
|
|
||||||
// Still write out whatever data was extracted.
|
|
||||||
cachedSongsDao.nukeSongs()
|
|
||||||
L.d("Successfully deleted old cache")
|
|
||||||
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
|
|
||||||
L.d("Successfully wrote ${rawSongs.size} songs to cache")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
L.e("Unable to save cache database.")
|
|
||||||
L.e(e.stackTraceToString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
|
|
||||||
* [CacheRepository].
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
interface Cache {
|
|
||||||
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
|
|
||||||
val invalidated: Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate a [RawSong] from a cache entry, if it exists.
|
|
||||||
*
|
|
||||||
* @param rawSong The [RawSong] to populate.
|
|
||||||
* @return true if a cache entry could be applied to [rawSong], false otherwise.
|
|
||||||
*/
|
|
||||||
fun populate(rawSong: RawSong): Boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
|
|
||||||
private val cacheMap = buildMap {
|
|
||||||
for (cachedSong in cachedSongs) {
|
|
||||||
put(cachedSong.mediaStoreId, cachedSong)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override var invalidated = false
|
|
||||||
|
|
||||||
override fun populate(rawSong: RawSong): Boolean {
|
|
||||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
|
||||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
|
||||||
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
|
||||||
// check for it anyway.
|
|
||||||
val cachedSong = cacheMap[rawSong.mediaStoreId]
|
|
||||||
if (cachedSong != null &&
|
|
||||||
cachedSong.dateAdded == rawSong.dateAdded &&
|
|
||||||
cachedSong.dateModified == rawSong.dateModified) {
|
|
||||||
cachedSong.copyToRaw(rawSong)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// We could not populate this song. This means our cache is stale and should be
|
|
||||||
// re-written with newly-loaded music.
|
|
||||||
invalidated = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
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.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.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -29,10 +29,11 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
||||||
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.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.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import org.oxycblt.musikr.Playlist
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -52,6 +53,9 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.lbl_confirm_delete_playlist)
|
.setTitle(R.string.lbl_confirm_delete_playlist)
|
||||||
.setPositiveButton(R.string.lbl_delete) { _, _ ->
|
.setPositiveButton(R.string.lbl_delete) { _, _ ->
|
||||||
|
// Normally the navigateUp will occur after this, which then collides with the
|
||||||
|
// playlist view's navigation. Forcefully navigate up to stop this.
|
||||||
|
findNavController().navigateUp()
|
||||||
// Now we can delete the playlist for-real this time.
|
// Now we can delete the playlist for-real this time.
|
||||||
musicModel.deletePlaylist(
|
musicModel.deletePlaylist(
|
||||||
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
|
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
|
||||||
|
|
|
@ -31,12 +31,13 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
|
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
|
||||||
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.external.ExportConfig
|
|
||||||
import org.oxycblt.auxio.music.external.M3U
|
|
||||||
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.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import org.oxycblt.musikr.Playlist
|
||||||
|
import org.oxycblt.musikr.playlist.ExportConfig
|
||||||
|
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -25,6 +25,7 @@ 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.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
|
||||||
|
|
||||||
|
|
|
@ -25,14 +25,14 @@ 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.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.list.Item
|
|
||||||
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.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
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.resolve
|
||||||
import org.oxycblt.auxio.music.external.ExportConfig
|
import org.oxycblt.musikr.Music
|
||||||
|
import org.oxycblt.musikr.Playlist
|
||||||
|
import org.oxycblt.musikr.Song
|
||||||
|
import org.oxycblt.musikr.playlist.ExportConfig
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
var refreshChoicesWith: List<Song>? = null
|
var refreshChoicesWith: List<Song>? = null
|
||||||
val deviceLibrary = musicRepository.deviceLibrary
|
val library = musicRepository.library
|
||||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
if (changes.deviceLibrary && library != null) {
|
||||||
_currentPendingNewPlaylist.value =
|
_currentPendingNewPlaylist.value =
|
||||||
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
|
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
|
||||||
PendingNewPlaylist(
|
PendingNewPlaylist(
|
||||||
pendingPlaylist.preferredName,
|
pendingPlaylist.preferredName,
|
||||||
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
|
pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) },
|
||||||
pendingPlaylist.template,
|
pendingPlaylist.template,
|
||||||
pendingPlaylist.reason)
|
pendingPlaylist.reason)
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
_currentSongsToAdd.value =
|
_currentSongsToAdd.value =
|
||||||
_currentSongsToAdd.value?.let { pendingSongs ->
|
_currentSongsToAdd.value?.let { pendingSongs ->
|
||||||
pendingSongs
|
pendingSongs
|
||||||
.mapNotNull { deviceLibrary.findSong(it.uid) }
|
.mapNotNull { library.findSong(it.uid) }
|
||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
.also { refreshChoicesWith = it }
|
.also { refreshChoicesWith = it }
|
||||||
}
|
}
|
||||||
|
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
|
|
||||||
_currentPlaylistToExport.value =
|
_currentPlaylistToExport.value =
|
||||||
_currentPlaylistToExport.value?.let { playlist ->
|
_currentPlaylistToExport.value?.let { playlist ->
|
||||||
musicRepository.userLibrary?.findPlaylist(playlist.uid)
|
musicRepository.library?.findPlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
|
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
|
||||||
}
|
}
|
||||||
|
@ -153,14 +153,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
reason: PlaylistDecision.New.Reason
|
reason: PlaylistDecision.New.Reason
|
||||||
) {
|
) {
|
||||||
L.d("Opening ${songUids.size} songs to create a playlist from")
|
L.d("Opening ${songUids.size} songs to create a playlist from")
|
||||||
val userLibrary = musicRepository.userLibrary ?: return
|
val library = musicRepository.library ?: return
|
||||||
val songs =
|
val songs =
|
||||||
musicRepository.deviceLibrary
|
musicRepository.library
|
||||||
?.let { songUids.mapNotNull(it::findSong) }
|
?.let { songUids.mapNotNull(it::findSong) }
|
||||||
?.also(::refreshPlaylistChoices)
|
?.also(::refreshPlaylistChoices)
|
||||||
|
|
||||||
val possibleName =
|
val possibleName =
|
||||||
musicRepository.userLibrary?.let {
|
musicRepository.library?.let {
|
||||||
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
||||||
var i = 1
|
var i = 1
|
||||||
var possibleName: String
|
var possibleName: String
|
||||||
|
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||||
L.d("Trying $possibleName as a playlist name")
|
L.d("Trying $possibleName as a playlist name")
|
||||||
++i
|
++i
|
||||||
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
|
} while (library.playlists.any { it.name.resolve(context) == possibleName })
|
||||||
L.d("$possibleName is unique, using it as the playlist name")
|
L.d("$possibleName is unique, using it as the playlist name")
|
||||||
possibleName
|
possibleName
|
||||||
}
|
}
|
||||||
|
@ -194,9 +194,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
reason: PlaylistDecision.Rename.Reason
|
reason: PlaylistDecision.Rename.Reason
|
||||||
) {
|
) {
|
||||||
L.d("Opening playlist $playlistUid to rename")
|
L.d("Opening playlist $playlistUid to rename")
|
||||||
val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
val playlist = musicRepository.library?.findPlaylist(playlistUid)
|
||||||
val applySongs =
|
val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) }
|
||||||
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
|
|
||||||
|
|
||||||
_currentPendingRenamePlaylist.value =
|
_currentPendingRenamePlaylist.value =
|
||||||
if (playlist != null && applySongs != null) {
|
if (playlist != null && applySongs != null) {
|
||||||
|
@ -216,7 +215,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
L.d("Opening playlist $playlistUid to export")
|
L.d("Opening playlist $playlistUid to export")
|
||||||
// TODO: Add this guard to the rest of the methods here
|
// TODO: Add this guard to the rest of the methods here
|
||||||
if (_currentPlaylistToExport.value?.uid == playlistUid) return
|
if (_currentPlaylistToExport.value?.uid == playlistUid) return
|
||||||
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
_currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid)
|
||||||
if (_currentPlaylistToExport.value == null) {
|
if (_currentPlaylistToExport.value == null) {
|
||||||
L.w("Given playlist UID to export was invalid")
|
L.w("Given playlist UID to export was invalid")
|
||||||
} else {
|
} else {
|
||||||
|
@ -241,7 +240,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
*/
|
*/
|
||||||
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
||||||
L.d("Opening playlist $playlistUid to delete")
|
L.d("Opening playlist $playlistUid to delete")
|
||||||
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
_currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid)
|
||||||
if (_currentPlaylistToDelete.value == null) {
|
if (_currentPlaylistToDelete.value == null) {
|
||||||
L.w("Given playlist UID to delete was invalid")
|
L.w("Given playlist UID to delete was invalid")
|
||||||
}
|
}
|
||||||
|
@ -266,8 +265,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
val userLibrary = musicRepository.userLibrary
|
val library = musicRepository.library
|
||||||
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
if (library != null && library.findPlaylistByName(trimmed) == null) {
|
||||||
L.d("Chosen name is valid")
|
L.d("Chosen name is valid")
|
||||||
ChosenName.Valid(trimmed)
|
ChosenName.Valid(trimmed)
|
||||||
} else {
|
} else {
|
||||||
|
@ -286,7 +285,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
||||||
L.d("Opening ${songUids.size} songs to add to a playlist")
|
L.d("Opening ${songUids.size} songs to add to a playlist")
|
||||||
_currentSongsToAdd.value =
|
_currentSongsToAdd.value =
|
||||||
musicRepository.deviceLibrary
|
musicRepository.library
|
||||||
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
||||||
?.also(::refreshPlaylistChoices)
|
?.also(::refreshPlaylistChoices)
|
||||||
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
||||||
|
@ -295,10 +294,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPlaylistChoices(songs: List<Song>) {
|
private fun refreshPlaylistChoices(songs: List<Song>) {
|
||||||
val userLibrary = musicRepository.userLibrary ?: return
|
val library = musicRepository.library ?: return
|
||||||
L.d("Refreshing playlist choices")
|
L.d("Refreshing playlist choices")
|
||||||
_playlistAddChoices.value =
|
_playlistAddChoices.value =
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(library.playlists).map {
|
||||||
val songSet = it.songs.toSet()
|
val songSet = it.songs.toSet()
|
||||||
PlaylistChoice(it, songs.all(songSet::contains))
|
PlaylistChoice(it, songs.all(songSet::contains))
|
||||||
}
|
}
|
||||||
|
@ -355,4 +354,4 @@ sealed interface ChosenName {
|
||||||
* [Playlist].
|
* [Playlist].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item
|
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean)
|
||||||
|
|
|
@ -30,6 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
|
import org.oxycblt.auxio.music.resolve
|
||||||
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.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
|
@ -1,383 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* DeviceLibrary.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.device
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
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.Song
|
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
|
||||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
|
||||||
import org.oxycblt.auxio.music.fs.useQuery
|
|
||||||
import org.oxycblt.auxio.music.info.Name
|
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
|
||||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
|
||||||
import org.oxycblt.auxio.util.sendWithTimeout
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Organized music library information obtained from device storage.
|
|
||||||
*
|
|
||||||
* This class allows for the creation of a well-formed music library graph from raw song
|
|
||||||
* information. Instances are immutable. It's generally not expected to create this yourself and
|
|
||||||
* instead use [MusicRepository].
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart
|
|
||||||
*/
|
|
||||||
interface DeviceLibrary {
|
|
||||||
/** All [Song]s in this [DeviceLibrary]. */
|
|
||||||
val songs: Collection<Song>
|
|
||||||
|
|
||||||
/** All [Album]s in this [DeviceLibrary]. */
|
|
||||||
val albums: Collection<Album>
|
|
||||||
|
|
||||||
/** All [Artist]s in this [DeviceLibrary]. */
|
|
||||||
val artists: Collection<Artist>
|
|
||||||
|
|
||||||
/** All [Genre]s in this [DeviceLibrary]. */
|
|
||||||
val genres: Collection<Genre>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Song] instance corresponding to the given [Music.UID].
|
|
||||||
*
|
|
||||||
* @param uid The [Music.UID] to search for.
|
|
||||||
* @return The corresponding [Song], or null if one was not found.
|
|
||||||
*/
|
|
||||||
fun findSong(uid: Music.UID): Song?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
|
||||||
*
|
|
||||||
* @param context [Context] required to analyze the [Uri].
|
|
||||||
* @param uri [Uri] to search for.
|
|
||||||
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
|
|
||||||
*/
|
|
||||||
fun findSongForUri(context: Context, uri: Uri): Song?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Song] instance corresponding to the given [Path].
|
|
||||||
*
|
|
||||||
* @param path [Path] to search for.
|
|
||||||
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
|
|
||||||
*/
|
|
||||||
fun findSongByPath(path: Path): Song?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Album] instance corresponding to the given [Music.UID].
|
|
||||||
*
|
|
||||||
* @param uid The [Music.UID] to search for.
|
|
||||||
* @return The corresponding [Album], or null if one was not found.
|
|
||||||
*/
|
|
||||||
fun findAlbum(uid: Music.UID): Album?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Artist] instance corresponding to the given [Music.UID].
|
|
||||||
*
|
|
||||||
* @param uid The [Music.UID] to search for.
|
|
||||||
* @return The corresponding [Artist], or null if one was not found.
|
|
||||||
*/
|
|
||||||
fun findArtist(uid: Music.UID): Artist?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a [Genre] instance corresponding to the given [Music.UID].
|
|
||||||
*
|
|
||||||
* @param uid The [Music.UID] to search for.
|
|
||||||
* @return The corresponding [Genre], or null if one was not found.
|
|
||||||
*/
|
|
||||||
fun findGenre(uid: Music.UID): Genre?
|
|
||||||
|
|
||||||
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
|
|
||||||
interface Factory {
|
|
||||||
/**
|
|
||||||
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
|
|
||||||
* [RawSong] instances.
|
|
||||||
*
|
|
||||||
* @param rawSongs A stream of [RawSong] instances to process.
|
|
||||||
* @param processedSongs A stream of [RawSong] instances that will have been processed by
|
|
||||||
* the instance.
|
|
||||||
*/
|
|
||||||
suspend fun create(
|
|
||||||
rawSongs: Channel<RawSong>,
|
|
||||||
processedSongs: Channel<RawSong>,
|
|
||||||
separators: Separators,
|
|
||||||
nameFactory: Name.Known.Factory
|
|
||||||
): DeviceLibraryImpl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
|
|
||||||
override suspend fun create(
|
|
||||||
rawSongs: Channel<RawSong>,
|
|
||||||
processedSongs: Channel<RawSong>,
|
|
||||||
separators: Separators,
|
|
||||||
nameFactory: Name.Known.Factory
|
|
||||||
): DeviceLibraryImpl {
|
|
||||||
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
|
|
||||||
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
|
|
||||||
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
|
|
||||||
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
|
|
||||||
|
|
||||||
// All music information is grouped as it is indexed by other components.
|
|
||||||
rawSongs.forEachWithTimeout { rawSong ->
|
|
||||||
val song = SongImpl(rawSong, nameFactory, separators)
|
|
||||||
// At times the indexer produces duplicate songs, try to filter these. Comparing by
|
|
||||||
// UID is sufficient for something like this, and also prevents collisions from
|
|
||||||
// causing severe issues elsewhere.
|
|
||||||
if (songGrouping.containsKey(song.uid)) {
|
|
||||||
L.w(
|
|
||||||
"Duplicate song found: ${song.path} " +
|
|
||||||
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
|
|
||||||
// We still want to say that we "processed" the song so that the user doesn't
|
|
||||||
// get confused at why the bar was only partly filled by the end of the loading
|
|
||||||
// process.
|
|
||||||
processedSongs.sendWithTimeout(rawSong)
|
|
||||||
return@forEachWithTimeout
|
|
||||||
}
|
|
||||||
songGrouping[song.uid] = song
|
|
||||||
|
|
||||||
// Group the new song into an album.
|
|
||||||
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
|
|
||||||
compareSongTracks(old, new)
|
|
||||||
}
|
|
||||||
// Group the song into each of it's artists.
|
|
||||||
for (rawArtist in song.rawArtists) {
|
|
||||||
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
|
|
||||||
// Artist information from earlier dates is prioritized, as it is less likely to
|
|
||||||
// change with the addition of new tracks. Fall back to the name otherwise.
|
|
||||||
check(old is SongImpl) // This should always be the case.
|
|
||||||
compareSongDates(old, new)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group the song into each of it's genres.
|
|
||||||
for (rawGenre in song.rawGenres) {
|
|
||||||
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
|
|
||||||
}
|
|
||||||
|
|
||||||
processedSongs.sendWithTimeout(rawSong)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now that all songs are processed, also process albums and group them into their
|
|
||||||
// respective artists.
|
|
||||||
pruneMusicBrainzIdTree(albumGrouping) { old, new ->
|
|
||||||
compareSongTracks(old, new)
|
|
||||||
}
|
|
||||||
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
|
|
||||||
for (album in albums) {
|
|
||||||
for (rawArtist in album.rawArtists) {
|
|
||||||
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
|
|
||||||
when (old) {
|
|
||||||
// Immediately replace any songs that initially held the priority position.
|
|
||||||
is SongImpl -> true
|
|
||||||
is AlbumImpl -> {
|
|
||||||
compareAlbumDates(old, new)
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artists and genres do not need to be grouped and can be processed immediately.
|
|
||||||
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
|
|
||||||
when {
|
|
||||||
// Immediately replace any songs that initially held the priority position.
|
|
||||||
old is SongImpl && new is AlbumImpl -> true
|
|
||||||
old is AlbumImpl && new is SongImpl -> false
|
|
||||||
old is SongImpl && new is SongImpl -> {
|
|
||||||
compareSongDates(old, new)
|
|
||||||
}
|
|
||||||
old is AlbumImpl && new is AlbumImpl -> {
|
|
||||||
compareAlbumDates(old, new)
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
|
|
||||||
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
|
|
||||||
|
|
||||||
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
|
|
||||||
music: N,
|
|
||||||
raw: R,
|
|
||||||
tree: MutableMap<String?, Grouping<R, O>>,
|
|
||||||
prioritize: (old: O, new: N) -> Boolean,
|
|
||||||
) {
|
|
||||||
val nameKey = raw.name?.lowercase()
|
|
||||||
val body = tree[nameKey]
|
|
||||||
if (body != null) {
|
|
||||||
body.music.add(music)
|
|
||||||
if (prioritize(body.raw.src, music)) {
|
|
||||||
body.raw = PrioritizedRaw(raw, music)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Need to initialize this grouping.
|
|
||||||
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
|
|
||||||
tree: MutableMap<String?, Grouping<R, O>>,
|
|
||||||
map: (Grouping<R, O>) -> P
|
|
||||||
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
|
|
||||||
|
|
||||||
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
|
|
||||||
music: N,
|
|
||||||
raw: R,
|
|
||||||
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
|
|
||||||
prioritize: (old: O, new: N) -> Boolean,
|
|
||||||
) {
|
|
||||||
val nameKey = raw.name?.lowercase()
|
|
||||||
val musicBrainzIdGroups = tree[nameKey]
|
|
||||||
if (musicBrainzIdGroups != null) {
|
|
||||||
val body = musicBrainzIdGroups[raw.musicBrainzId]
|
|
||||||
if (body != null) {
|
|
||||||
body.music.add(music)
|
|
||||||
if (prioritize(body.raw.src, music)) {
|
|
||||||
body.raw = PrioritizedRaw(raw, music)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Need to initialize this grouping.
|
|
||||||
musicBrainzIdGroups[raw.musicBrainzId] =
|
|
||||||
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Need to initialize this grouping.
|
|
||||||
tree[nameKey] =
|
|
||||||
mutableMapOf(
|
|
||||||
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
|
|
||||||
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
|
|
||||||
prioritize: (old: M, new: M) -> Boolean
|
|
||||||
) {
|
|
||||||
for ((_, musicBrainzIdGroups) in tree) {
|
|
||||||
var nullGroup = musicBrainzIdGroups[null]
|
|
||||||
if (nullGroup == null) {
|
|
||||||
// Full MusicBrainz ID tagging. Nothing to do.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
|
|
||||||
// collapse all of them into the null group.
|
|
||||||
// TODO: More advanced heuristics eventually (tm)
|
|
||||||
musicBrainzIdGroups
|
|
||||||
.filter { it.key != null }
|
|
||||||
.forEach {
|
|
||||||
val (_, group) = it
|
|
||||||
nullGroup.music.addAll(group.music)
|
|
||||||
if (prioritize(group.raw.src, nullGroup.raw.src)) {
|
|
||||||
nullGroup.raw = group.raw
|
|
||||||
}
|
|
||||||
musicBrainzIdGroups.remove(it.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
|
|
||||||
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
|
|
||||||
map: (Grouping<R, M>) -> T
|
|
||||||
): Set<T> {
|
|
||||||
val result = mutableSetOf<T>()
|
|
||||||
for ((_, musicBrainzIdGroups) in tree) {
|
|
||||||
for (group in musicBrainzIdGroups.values) {
|
|
||||||
result += map(group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
|
|
||||||
new.track != null &&
|
|
||||||
(old.track == null ||
|
|
||||||
new.track < old.track ||
|
|
||||||
(new.track == old.track && new.name < old.name))
|
|
||||||
|
|
||||||
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
|
|
||||||
new.dates != null &&
|
|
||||||
(old.dates == null ||
|
|
||||||
new.dates < old.dates ||
|
|
||||||
(new.dates == old.dates && new.name < old.name))
|
|
||||||
|
|
||||||
private fun compareSongDates(old: SongImpl, new: SongImpl) =
|
|
||||||
new.date != null &&
|
|
||||||
(old.date == null ||
|
|
||||||
new.date < old.date ||
|
|
||||||
(new.date == old.date && new.name < old.name))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Avoid redundant data creation
|
|
||||||
|
|
||||||
class DeviceLibraryImpl(
|
|
||||||
override val songs: Collection<SongImpl>,
|
|
||||||
override val albums: Collection<AlbumImpl>,
|
|
||||||
override val artists: Collection<ArtistImpl>,
|
|
||||||
override val genres: Collection<GenreImpl>
|
|
||||||
) : DeviceLibrary {
|
|
||||||
// Use a mapping to make finding information based on it's UID much faster.
|
|
||||||
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
|
|
||||||
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
|
|
||||||
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
|
|
||||||
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
|
|
||||||
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
|
|
||||||
|
|
||||||
// All other music is built from songs, so comparison only needs to check songs.
|
|
||||||
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
|
|
||||||
|
|
||||||
override fun hashCode() = songs.hashCode()
|
|
||||||
|
|
||||||
override fun toString() =
|
|
||||||
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
|
|
||||||
"artists=${artists.size}, genres=${genres.size})"
|
|
||||||
|
|
||||||
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
|
|
||||||
|
|
||||||
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
|
|
||||||
|
|
||||||
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
|
|
||||||
|
|
||||||
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
|
|
||||||
|
|
||||||
override fun findSongByPath(path: Path) = songPathMap[path]
|
|
||||||
|
|
||||||
override fun findSongForUri(context: Context, uri: Uri) =
|
|
||||||
context.contentResolverSafe.useQuery(
|
|
||||||
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
|
||||||
// song. Do what we can to hopefully find the song the user wanted to open.
|
|
||||||
val displayName =
|
|
||||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
|
||||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
|
||||||
songs.find { it.path.name == displayName && it.size == size }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,624 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* DeviceMusicImpl.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.device
|
|
||||||
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.image.extractor.Cover
|
|
||||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.music.Artist
|
|
||||||
import org.oxycblt.auxio.music.Genre
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.MusicType
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.fs.MimeType
|
|
||||||
import org.oxycblt.auxio.music.fs.toAlbumCoverUri
|
|
||||||
import org.oxycblt.auxio.music.fs.toAudioUri
|
|
||||||
import org.oxycblt.auxio.music.fs.toSongCoverUri
|
|
||||||
import org.oxycblt.auxio.music.info.Date
|
|
||||||
import org.oxycblt.auxio.music.info.Disc
|
|
||||||
import org.oxycblt.auxio.music.info.Name
|
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
|
||||||
import org.oxycblt.auxio.music.metadata.Separators
|
|
||||||
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
|
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
|
|
||||||
import org.oxycblt.auxio.util.positiveOrNull
|
|
||||||
import org.oxycblt.auxio.util.toUuidOrNull
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
|
||||||
import org.oxycblt.auxio.util.update
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library-backed implementation of [Song].
|
|
||||||
*
|
|
||||||
* @param rawSong The [RawSong] to derive the member data from.
|
|
||||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
|
||||||
* @param separators The [Separators] to parse multi-value tags with.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class SongImpl(
|
|
||||||
private val rawSong: RawSong,
|
|
||||||
private val nameFactory: Name.Known.Factory,
|
|
||||||
private val separators: Separators
|
|
||||||
) : Song {
|
|
||||||
override val uid =
|
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
|
||||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
|
|
||||||
?: Music.UID.auxio(MusicType.SONGS) {
|
|
||||||
// Song UIDs are based on the raw data without parsing so that they remain
|
|
||||||
// consistent across music setting changes. Parents are not held up to the
|
|
||||||
// same standard since grouping is already inherently linked to settings.
|
|
||||||
update(rawSong.name)
|
|
||||||
update(rawSong.albumName)
|
|
||||||
update(rawSong.date)
|
|
||||||
|
|
||||||
update(rawSong.track)
|
|
||||||
update(rawSong.disc)
|
|
||||||
|
|
||||||
update(rawSong.artistNames)
|
|
||||||
update(rawSong.albumArtistNames)
|
|
||||||
}
|
|
||||||
override val name =
|
|
||||||
nameFactory.parse(
|
|
||||||
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" },
|
|
||||||
rawSong.sortName)
|
|
||||||
|
|
||||||
override val track = rawSong.track
|
|
||||||
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
|
|
||||||
override val date = rawSong.date
|
|
||||||
override val uri =
|
|
||||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toAudioUri()
|
|
||||||
override val path = requireNotNull(rawSong.path) { "Invalid raw ${rawSong.path}: No path" }
|
|
||||||
override val mimeType =
|
|
||||||
MimeType(
|
|
||||||
fromExtension =
|
|
||||||
requireNotNull(rawSong.extensionMimeType) {
|
|
||||||
"Invalid raw ${rawSong.path}: No mime type"
|
|
||||||
},
|
|
||||||
fromFormat = null)
|
|
||||||
override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.path}: No size" }
|
|
||||||
override val durationMs =
|
|
||||||
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.path}: No duration" }
|
|
||||||
override val replayGainAdjustment =
|
|
||||||
ReplayGainAdjustment(
|
|
||||||
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
|
|
||||||
|
|
||||||
override val dateAdded =
|
|
||||||
requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.path}: No date added" }
|
|
||||||
|
|
||||||
private var _album: AlbumImpl? = null
|
|
||||||
override val album: Album
|
|
||||||
get() = unlikelyToBeNull(_album)
|
|
||||||
|
|
||||||
private val _artists = mutableListOf<ArtistImpl>()
|
|
||||||
override val artists: List<Artist>
|
|
||||||
get() = _artists
|
|
||||||
|
|
||||||
private val _genres = mutableListOf<GenreImpl>()
|
|
||||||
override val genres: List<Genre>
|
|
||||||
get() = _genres
|
|
||||||
|
|
||||||
override val cover =
|
|
||||||
rawSong.coverPerceptualHash?.let {
|
|
||||||
// We were able to confirm that the song had a parsable cover and can be used on
|
|
||||||
// a per-song basis. Otherwise, just fall back to a per-album cover instead, as
|
|
||||||
// it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not
|
|
||||||
// support the cover metadata of a given spec (unlikely).
|
|
||||||
Cover.Embedded(
|
|
||||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }
|
|
||||||
.toSongCoverUri(),
|
|
||||||
uri,
|
|
||||||
it)
|
|
||||||
} ?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
|
|
||||||
* [Album].
|
|
||||||
*/
|
|
||||||
val rawAlbum: RawAlbum
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The [RawArtist] instances collated by the [Song]. The artists of the song take priority,
|
|
||||||
* followed by the album artists. If there are no artists, this field will be a single "unknown"
|
|
||||||
* [RawArtist]. This can be used to group up [Song]s into an [Artist].
|
|
||||||
*/
|
|
||||||
val rawArtists: List<RawArtist>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a
|
|
||||||
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names.
|
|
||||||
*/
|
|
||||||
val rawGenres: List<RawGenre>
|
|
||||||
|
|
||||||
private var hashCode: Int = uid.hashCode()
|
|
||||||
|
|
||||||
init {
|
|
||||||
val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds)
|
|
||||||
val artistNames = separators.split(rawSong.artistNames)
|
|
||||||
val artistSortNames = separators.split(rawSong.artistSortNames)
|
|
||||||
val rawIndividualArtists =
|
|
||||||
artistNames
|
|
||||||
.mapIndexed { i, name ->
|
|
||||||
RawArtist(
|
|
||||||
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
|
||||||
name,
|
|
||||||
artistSortNames.getOrNull(i))
|
|
||||||
}
|
|
||||||
// Some songs have the same artist listed multiple times (sometimes with different
|
|
||||||
// casing!),
|
|
||||||
// so we need to deduplicate lest finalization reordering fails.
|
|
||||||
// Since MBID data can wind up clobbered later in the grouper, we can't really
|
|
||||||
// use it to deduplicate. That means that a hypothetical track with two artists
|
|
||||||
// of the same name but different MBIDs will be grouped wrong. That is a bridge
|
|
||||||
// I will cross when I get to it.
|
|
||||||
.distinctBy { it.name?.lowercase() }
|
|
||||||
|
|
||||||
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
|
|
||||||
val albumArtistNames = separators.split(rawSong.albumArtistNames)
|
|
||||||
val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
|
|
||||||
val rawAlbumArtists =
|
|
||||||
albumArtistNames
|
|
||||||
.mapIndexed { i, name ->
|
|
||||||
RawArtist(
|
|
||||||
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
|
||||||
name,
|
|
||||||
albumArtistSortNames.getOrNull(i))
|
|
||||||
}
|
|
||||||
.distinctBy { it.name?.lowercase() }
|
|
||||||
|
|
||||||
rawAlbum =
|
|
||||||
RawAlbum(
|
|
||||||
mediaStoreId =
|
|
||||||
requireNotNull(rawSong.albumMediaStoreId) {
|
|
||||||
"Invalid raw ${rawSong.path}: No album id"
|
|
||||||
},
|
|
||||||
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
|
|
||||||
name =
|
|
||||||
requireNotNull(rawSong.albumName) {
|
|
||||||
"Invalid raw ${rawSong.path}: No album name"
|
|
||||||
},
|
|
||||||
sortName = rawSong.albumSortName,
|
|
||||||
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
|
|
||||||
rawArtists =
|
|
||||||
rawAlbumArtists
|
|
||||||
.ifEmpty { rawIndividualArtists }
|
|
||||||
.ifEmpty { listOf(RawArtist()) })
|
|
||||||
|
|
||||||
rawArtists =
|
|
||||||
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
|
|
||||||
|
|
||||||
val genreNames =
|
|
||||||
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
|
|
||||||
rawGenres =
|
|
||||||
genreNames
|
|
||||||
.map { RawGenre(it) }
|
|
||||||
.distinctBy { it.name?.lowercase() }
|
|
||||||
.ifEmpty { listOf(RawGenre()) }
|
|
||||||
|
|
||||||
hashCode = 31 * hashCode + rawSong.hashCode()
|
|
||||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
|
||||||
|
|
||||||
// Since equality on public-facing music models is not identical to the tag equality,
|
|
||||||
// we just compare raw instances and how they are interpreted.
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is SongImpl &&
|
|
||||||
uid == other.uid &&
|
|
||||||
nameFactory == other.nameFactory &&
|
|
||||||
separators == other.separators &&
|
|
||||||
rawSong == other.rawSong
|
|
||||||
|
|
||||||
override fun toString() = "Song(uid=$uid, name=$name)"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links this [Song] with a parent [Album].
|
|
||||||
*
|
|
||||||
* @param album The parent [Album] to link to.
|
|
||||||
*/
|
|
||||||
fun link(album: AlbumImpl) {
|
|
||||||
_album = album
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links this [Song] with a parent [Artist].
|
|
||||||
*
|
|
||||||
* @param artist The parent [Artist] to link to.
|
|
||||||
*/
|
|
||||||
fun link(artist: ArtistImpl) {
|
|
||||||
_artists.add(artist)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links this [Song] with a parent [Genre].
|
|
||||||
*
|
|
||||||
* @param genre The parent [Genre] to link to.
|
|
||||||
*/
|
|
||||||
fun link(genre: GenreImpl) {
|
|
||||||
_genres.add(genre)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform final validation and organization on this instance.
|
|
||||||
*
|
|
||||||
* @return This instance upcasted to [Song].
|
|
||||||
*/
|
|
||||||
fun finalize(): Song {
|
|
||||||
checkNotNull(_album) { "Malformed song ${path}: No album" }
|
|
||||||
|
|
||||||
check(_artists.isNotEmpty()) { "Malformed song ${path}: No artists" }
|
|
||||||
check(_artists.size == rawArtists.size) {
|
|
||||||
"Malformed song ${path}: Artist grouping mismatch"
|
|
||||||
}
|
|
||||||
for (i in _artists.indices) {
|
|
||||||
// Non-destructively reorder the linked artists so that they align with
|
|
||||||
// the artist ordering within the song metadata.
|
|
||||||
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
|
|
||||||
val other = _artists[newIdx]
|
|
||||||
_artists[newIdx] = _artists[i]
|
|
||||||
_artists[i] = other
|
|
||||||
}
|
|
||||||
|
|
||||||
check(_genres.isNotEmpty()) { "Malformed song ${path}: No genres" }
|
|
||||||
check(_genres.size == rawGenres.size) { "Malformed song ${path}: Genre grouping mismatch" }
|
|
||||||
for (i in _genres.indices) {
|
|
||||||
// Non-destructively reorder the linked genres so that they align with
|
|
||||||
// the genre ordering within the song metadata.
|
|
||||||
val newIdx = _genres[i].getOriginalPositionIn(rawGenres)
|
|
||||||
val other = _genres[newIdx]
|
|
||||||
_genres[newIdx] = _genres[i]
|
|
||||||
_genres[i] = other
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library-backed implementation of [Album].
|
|
||||||
*
|
|
||||||
* @param grouping [Grouping] to derive the member data from.
|
|
||||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class AlbumImpl(
|
|
||||||
grouping: Grouping<RawAlbum, SongImpl>,
|
|
||||||
private val nameFactory: Name.Known.Factory
|
|
||||||
) : Album {
|
|
||||||
private val rawAlbum = grouping.raw.inner
|
|
||||||
|
|
||||||
override val uid =
|
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
|
||||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
|
|
||||||
?: Music.UID.auxio(MusicType.ALBUMS) {
|
|
||||||
// Hash based on only names despite the presence of a date to increase stability.
|
|
||||||
// I don't know if there is any situation where an artist will have two albums with
|
|
||||||
// the exact same name, but if there is, I would love to know.
|
|
||||||
update(rawAlbum.name)
|
|
||||||
update(rawAlbum.rawArtists.map { it.name })
|
|
||||||
}
|
|
||||||
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
|
|
||||||
override val dates: Date.Range?
|
|
||||||
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
|
||||||
override val durationMs: Long
|
|
||||||
override val dateAdded: Long
|
|
||||||
override val cover: ParentCover
|
|
||||||
|
|
||||||
private val _artists = mutableListOf<ArtistImpl>()
|
|
||||||
override val artists: List<Artist>
|
|
||||||
get() = _artists
|
|
||||||
|
|
||||||
override val songs: Set<Song> = grouping.music
|
|
||||||
|
|
||||||
private var hashCode = uid.hashCode()
|
|
||||||
|
|
||||||
init {
|
|
||||||
var totalDuration: Long = 0
|
|
||||||
var minDate: Date? = null
|
|
||||||
var maxDate: Date? = null
|
|
||||||
var earliestDateAdded: Long = Long.MAX_VALUE
|
|
||||||
|
|
||||||
// Do linking and value generation in the same loop for efficiency.
|
|
||||||
for (song in grouping.music) {
|
|
||||||
song.link(this)
|
|
||||||
|
|
||||||
if (song.date != null) {
|
|
||||||
val min = minDate
|
|
||||||
if (min == null || song.date < min) {
|
|
||||||
minDate = song.date
|
|
||||||
}
|
|
||||||
|
|
||||||
val max = maxDate
|
|
||||||
if (max == null || song.date > max) {
|
|
||||||
maxDate = song.date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (song.dateAdded < earliestDateAdded) {
|
|
||||||
earliestDateAdded = song.dateAdded
|
|
||||||
}
|
|
||||||
totalDuration += song.durationMs
|
|
||||||
}
|
|
||||||
|
|
||||||
val min = minDate
|
|
||||||
val max = maxDate
|
|
||||||
dates = if (min != null && max != null) Date.Range(min, max) else null
|
|
||||||
durationMs = totalDuration
|
|
||||||
dateAdded = earliestDateAdded
|
|
||||||
|
|
||||||
cover = ParentCover.from(grouping.raw.src.cover, songs)
|
|
||||||
|
|
||||||
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
|
||||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
|
||||||
|
|
||||||
// Since equality on public-facing music models is not identical to the tag equality,
|
|
||||||
// we just compare raw instances and how they are interpreted.
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is AlbumImpl &&
|
|
||||||
uid == other.uid &&
|
|
||||||
rawAlbum == other.rawAlbum &&
|
|
||||||
nameFactory == other.nameFactory &&
|
|
||||||
songs == other.songs
|
|
||||||
|
|
||||||
override fun toString() = "Album(uid=$uid, name=$name)"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
|
|
||||||
* priority, followed by the artists. If there are no artists, this field will be a single
|
|
||||||
* "unknown" [RawArtist]. This can be used to group up [Album]s into an [Artist].
|
|
||||||
*/
|
|
||||||
val rawArtists = rawAlbum.rawArtists
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links this [Album] with a parent [Artist].
|
|
||||||
*
|
|
||||||
* @param artist The parent [Artist] to link to.
|
|
||||||
*/
|
|
||||||
fun link(artist: ArtistImpl) {
|
|
||||||
_artists.add(artist)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform final validation and organization on this instance.
|
|
||||||
*
|
|
||||||
* @return This instance upcasted to [Album].
|
|
||||||
*/
|
|
||||||
fun finalize(): Album {
|
|
||||||
check(songs.isNotEmpty()) { "Malformed album $name: Empty" }
|
|
||||||
check(_artists.isNotEmpty()) { "Malformed album $name: No artists" }
|
|
||||||
check(_artists.size == rawArtists.size) {
|
|
||||||
"Malformed album $name: Artist grouping mismatch"
|
|
||||||
}
|
|
||||||
for (i in _artists.indices) {
|
|
||||||
// Non-destructively reorder the linked artists so that they align with
|
|
||||||
// the artist ordering within the song metadata.
|
|
||||||
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
|
|
||||||
val other = _artists[newIdx]
|
|
||||||
_artists[newIdx] = _artists[i]
|
|
||||||
_artists[i] = other
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library-backed implementation of [Artist].
|
|
||||||
*
|
|
||||||
* @param grouping [Grouping] to derive the member data from.
|
|
||||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class ArtistImpl(
|
|
||||||
grouping: Grouping<RawArtist, Music>,
|
|
||||||
private val nameFactory: Name.Known.Factory
|
|
||||||
) : Artist {
|
|
||||||
private val rawArtist = grouping.raw.inner
|
|
||||||
|
|
||||||
override val uid =
|
|
||||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
|
||||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
|
|
||||||
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
|
|
||||||
override val name =
|
|
||||||
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
|
|
||||||
?: Name.Unknown(R.string.def_artist)
|
|
||||||
|
|
||||||
override val songs: Set<Song>
|
|
||||||
override val explicitAlbums: Set<Album>
|
|
||||||
override val implicitAlbums: Set<Album>
|
|
||||||
override val durationMs: Long?
|
|
||||||
override val cover: ParentCover
|
|
||||||
|
|
||||||
override lateinit var genres: List<Genre>
|
|
||||||
|
|
||||||
private var hashCode = uid.hashCode()
|
|
||||||
|
|
||||||
init {
|
|
||||||
val distinctSongs = mutableSetOf<Song>()
|
|
||||||
val albumMap = mutableMapOf<Album, Boolean>()
|
|
||||||
|
|
||||||
for (music in grouping.music) {
|
|
||||||
when (music) {
|
|
||||||
is SongImpl -> {
|
|
||||||
music.link(this)
|
|
||||||
distinctSongs.add(music)
|
|
||||||
if (albumMap[music.album] == null) {
|
|
||||||
albumMap[music.album] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is AlbumImpl -> {
|
|
||||||
music.link(this)
|
|
||||||
albumMap[music] = true
|
|
||||||
}
|
|
||||||
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
songs = distinctSongs
|
|
||||||
val albums = albumMap.keys
|
|
||||||
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
|
|
||||||
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
|
|
||||||
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
|
|
||||||
|
|
||||||
val singleCover =
|
|
||||||
when (val src = grouping.raw.src) {
|
|
||||||
is SongImpl -> src.cover
|
|
||||||
is AlbumImpl -> src.cover.single
|
|
||||||
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
|
|
||||||
}
|
|
||||||
cover = ParentCover.from(singleCover, songs)
|
|
||||||
|
|
||||||
hashCode = 31 * hashCode + rawArtist.hashCode()
|
|
||||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Append song contents to MusicParent equality so that artists with
|
|
||||||
// the same UID but different songs are not equal.
|
|
||||||
override fun hashCode() = hashCode
|
|
||||||
|
|
||||||
// Since equality on public-facing music models is not identical to the tag equality,
|
|
||||||
// we just compare raw instances and how they are interpreted.
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is ArtistImpl &&
|
|
||||||
uid == other.uid &&
|
|
||||||
rawArtist == other.rawArtist &&
|
|
||||||
nameFactory == other.nameFactory &&
|
|
||||||
songs == other.songs
|
|
||||||
|
|
||||||
override fun toString() = "Artist(uid=$uid, name=$name)"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
|
|
||||||
* list. This can be used to create a consistent ordering within child [Artist] lists based on
|
|
||||||
* the original tag order.
|
|
||||||
*
|
|
||||||
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s
|
|
||||||
* [RawArtist] will be within the list.
|
|
||||||
* @return The index of the [Artist]'s [RawArtist] within the list.
|
|
||||||
*/
|
|
||||||
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
|
|
||||||
rawArtists.indexOfFirst { it.name?.lowercase() == rawArtist.name?.lowercase() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform final validation and organization on this instance.
|
|
||||||
*
|
|
||||||
* @return This instance upcasted to [Artist].
|
|
||||||
*/
|
|
||||||
fun finalize(): Artist {
|
|
||||||
// There are valid artist configurations:
|
|
||||||
// 1. No songs, no implicit albums, some explicit albums
|
|
||||||
// 2. Some songs, no implicit albums, some explicit albums
|
|
||||||
// 3. Some songs, some implicit albums, no implicit albums
|
|
||||||
// 4. Some songs, some implicit albums, some explicit albums
|
|
||||||
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
|
|
||||||
// but I can't be 100% certain.
|
|
||||||
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
|
|
||||||
"Malformed artist $name: Empty"
|
|
||||||
}
|
|
||||||
genres =
|
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
|
||||||
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
|
|
||||||
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library-backed implementation of [Genre].
|
|
||||||
*
|
|
||||||
* @param grouping [Grouping] to derive the member data from.
|
|
||||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class GenreImpl(
|
|
||||||
grouping: Grouping<RawGenre, SongImpl>,
|
|
||||||
private val nameFactory: Name.Known.Factory
|
|
||||||
) : Genre {
|
|
||||||
private val rawGenre = grouping.raw.inner
|
|
||||||
|
|
||||||
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
|
|
||||||
override val name =
|
|
||||||
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
|
|
||||||
?: Name.Unknown(R.string.def_genre)
|
|
||||||
|
|
||||||
override val songs: Set<Song>
|
|
||||||
override val artists: Set<Artist>
|
|
||||||
override val durationMs: Long
|
|
||||||
override val cover: ParentCover
|
|
||||||
|
|
||||||
private var hashCode = uid.hashCode()
|
|
||||||
|
|
||||||
init {
|
|
||||||
val distinctArtists = mutableSetOf<Artist>()
|
|
||||||
var totalDuration = 0L
|
|
||||||
|
|
||||||
for (song in grouping.music) {
|
|
||||||
song.link(this)
|
|
||||||
distinctArtists.addAll(song.artists)
|
|
||||||
totalDuration += song.durationMs
|
|
||||||
}
|
|
||||||
|
|
||||||
songs = grouping.music
|
|
||||||
artists = distinctArtists
|
|
||||||
durationMs = totalDuration
|
|
||||||
|
|
||||||
cover = ParentCover.from(grouping.raw.src.cover, songs)
|
|
||||||
|
|
||||||
hashCode = 31 * hashCode + rawGenre.hashCode()
|
|
||||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
|
||||||
hashCode = 31 * hashCode + songs.hashCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
|
||||||
other is GenreImpl &&
|
|
||||||
uid == other.uid &&
|
|
||||||
rawGenre == other.rawGenre &&
|
|
||||||
nameFactory == other.nameFactory &&
|
|
||||||
songs == other.songs
|
|
||||||
|
|
||||||
override fun toString() = "Genre(uid=$uid, name=$name)"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
|
|
||||||
* This can be used to create a consistent ordering within child [Genre] lists based on the
|
|
||||||
* original tag order.
|
|
||||||
*
|
|
||||||
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's
|
|
||||||
* [RawGenre] will be within the list.
|
|
||||||
* @return The index of the [Genre]'s [RawGenre] within the list.
|
|
||||||
*/
|
|
||||||
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
|
|
||||||
rawGenres.indexOfFirst { it.name?.lowercase() == rawGenre.name?.lowercase() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform final validation and organization on this instance.
|
|
||||||
*
|
|
||||||
* @return This instance upcasted to [Genre].
|
|
||||||
*/
|
|
||||||
fun finalize(): Genre {
|
|
||||||
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,170 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* RawMusic.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.device
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
import org.oxycblt.auxio.music.Album
|
|
||||||
import org.oxycblt.auxio.music.Music
|
|
||||||
import org.oxycblt.auxio.music.Song
|
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
|
||||||
import org.oxycblt.auxio.music.info.Date
|
|
||||||
import org.oxycblt.auxio.music.info.ReleaseType
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
data class RawSong(
|
|
||||||
/**
|
|
||||||
* The ID of the [SongImpl]'s audio file, obtained from MediaStore. Note that this ID is highly
|
|
||||||
* unstable and should only be used for accessing the audio file.
|
|
||||||
*/
|
|
||||||
var mediaStoreId: Long? = null,
|
|
||||||
/** @see Song.dateAdded */
|
|
||||||
var dateAdded: Long? = null,
|
|
||||||
/** The latest date the [SongImpl]'s audio file was modified, as a unix epoch timestamp. */
|
|
||||||
var dateModified: Long? = null,
|
|
||||||
/** @see Song.path */
|
|
||||||
var path: Path? = null,
|
|
||||||
/** @see Song.size */
|
|
||||||
var size: Long? = null,
|
|
||||||
/** @see Song.durationMs */
|
|
||||||
var durationMs: Long? = null,
|
|
||||||
/** @see Song.mimeType */
|
|
||||||
var extensionMimeType: String? = null,
|
|
||||||
/** @see Song.replayGainAdjustment */
|
|
||||||
var replayGainTrackAdjustment: Float? = null,
|
|
||||||
/** @see Song.replayGainAdjustment */
|
|
||||||
var replayGainAlbumAdjustment: Float? = null,
|
|
||||||
/** @see Music.UID */
|
|
||||||
var musicBrainzId: String? = null,
|
|
||||||
/** @see Music.name */
|
|
||||||
var name: String? = null,
|
|
||||||
/** @see Music.name */
|
|
||||||
var sortName: String? = null,
|
|
||||||
/** @see Song.track */
|
|
||||||
var track: Int? = null,
|
|
||||||
/** @see Song.disc */
|
|
||||||
var disc: Int? = null,
|
|
||||||
/** @See Song.disc */
|
|
||||||
var subtitle: String? = null,
|
|
||||||
/** @see Song.date */
|
|
||||||
var date: Date? = null,
|
|
||||||
/** @see Song.cover */
|
|
||||||
var coverPerceptualHash: String? = null,
|
|
||||||
/** @see RawAlbum.mediaStoreId */
|
|
||||||
var albumMediaStoreId: Long? = null,
|
|
||||||
/** @see RawAlbum.musicBrainzId */
|
|
||||||
var albumMusicBrainzId: String? = null,
|
|
||||||
/** @see RawAlbum.name */
|
|
||||||
var albumName: String? = null,
|
|
||||||
/** @see RawAlbum.sortName */
|
|
||||||
var albumSortName: String? = null,
|
|
||||||
/** @see RawAlbum.releaseType */
|
|
||||||
var releaseTypes: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.musicBrainzId */
|
|
||||||
var artistMusicBrainzIds: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.name */
|
|
||||||
var artistNames: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.sortName */
|
|
||||||
var artistSortNames: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.musicBrainzId */
|
|
||||||
var albumArtistMusicBrainzIds: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.name */
|
|
||||||
var albumArtistNames: List<String> = listOf(),
|
|
||||||
/** @see RawArtist.sortName */
|
|
||||||
var albumArtistSortNames: List<String> = listOf(),
|
|
||||||
/** @see RawGenre.name */
|
|
||||||
var genreNames: List<String> = listOf()
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
data class RawAlbum(
|
|
||||||
/**
|
|
||||||
* The ID of the [AlbumImpl]'s grouping, obtained from MediaStore. Note that this ID is highly
|
|
||||||
* unstable and should only be used for accessing the system-provided cover art.
|
|
||||||
*/
|
|
||||||
val mediaStoreId: Long,
|
|
||||||
/** @see Music.uid */
|
|
||||||
override val musicBrainzId: UUID?,
|
|
||||||
/** @see Music.name */
|
|
||||||
override val name: String,
|
|
||||||
/** @see Music.name */
|
|
||||||
val sortName: String?,
|
|
||||||
/** @see Album.releaseType */
|
|
||||||
val releaseType: ReleaseType?,
|
|
||||||
/** @see RawArtist.name */
|
|
||||||
val rawArtists: List<RawArtist>
|
|
||||||
) : MusicBrainzGroupable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
|
|
||||||
* instances.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
data class RawArtist(
|
|
||||||
/** @see Music.UID */
|
|
||||||
override val musicBrainzId: UUID? = null,
|
|
||||||
/** @see Music.name */
|
|
||||||
override val name: String? = null,
|
|
||||||
/** @see Music.name */
|
|
||||||
val sortName: String? = null
|
|
||||||
) : MusicBrainzGroupable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
data class RawGenre(
|
|
||||||
/** @see Music.name */
|
|
||||||
override val name: String? = null
|
|
||||||
) : NameGroupable
|
|
||||||
|
|
||||||
interface NameGroupable {
|
|
||||||
val name: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MusicBrainzGroupable : NameGroupable {
|
|
||||||
val musicBrainzId: UUID?
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents grouped music information and the prioritized raw information to eventually derive a
|
|
||||||
* [Music] implementation instance from.
|
|
||||||
*
|
|
||||||
* @param raw The current [PrioritizedRaw] that will be used for the finalized music information.
|
|
||||||
* @param music The child [Music] instances of the music information to be created.
|
|
||||||
*/
|
|
||||||
data class Grouping<R, M : Music>(var raw: PrioritizedRaw<R, M>, val music: MutableSet<M>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a [RawAlbum], [RawArtist], or [RawGenre] specifically chosen to create a [Music]
|
|
||||||
* instance from due to it being the most likely source of truth.
|
|
||||||
*
|
|
||||||
* @param inner The raw music instance that will be used.
|
|
||||||
* @param src The [Music] instance that the raw information was derived from.
|
|
||||||
*/
|
|
||||||
data class PrioritizedRaw<R, M : Music>(val inner: R, val src: M)
|
|
|
@ -1,122 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 Auxio Project
|
|
||||||
* DirectoryAdapter.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.dirs
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.oxycblt.auxio.databinding.ItemMusicDirBinding
|
|
||||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
|
||||||
import org.oxycblt.auxio.util.context
|
|
||||||
import org.oxycblt.auxio.util.inflater
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [RecyclerView.Adapter] that manages a list of [Path] music directory instances.
|
|
||||||
*
|
|
||||||
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class DirectoryAdapter(private val listener: Listener) :
|
|
||||||
RecyclerView.Adapter<MusicDirViewHolder>() {
|
|
||||||
private val _dirs = mutableListOf<Path>()
|
|
||||||
/** The current list of [Path]s, may not line up with [MusicDirectories] due to removals. */
|
|
||||||
val dirs: List<Path> = _dirs
|
|
||||||
|
|
||||||
override fun getItemCount() = dirs.size
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
||||||
MusicDirViewHolder.from(parent)
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
|
|
||||||
holder.bind(dirs[position], listener)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a [Path] to the end of the list.
|
|
||||||
*
|
|
||||||
* @param path The [Path] to add.
|
|
||||||
*/
|
|
||||||
fun add(path: Path) {
|
|
||||||
if (_dirs.contains(path)) return
|
|
||||||
L.d("Adding $path")
|
|
||||||
_dirs.add(path)
|
|
||||||
notifyItemInserted(_dirs.lastIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a list of [Path] instances to the end of the list.
|
|
||||||
*
|
|
||||||
* @param path The [Path] instances to add.
|
|
||||||
*/
|
|
||||||
fun addAll(path: List<Path>) {
|
|
||||||
L.d("Adding ${path.size} directories")
|
|
||||||
val oldLastIndex = path.lastIndex
|
|
||||||
_dirs.addAll(path)
|
|
||||||
notifyItemRangeInserted(oldLastIndex, path.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a [Path] from the list.
|
|
||||||
*
|
|
||||||
* @param path The [Path] to remove. Must exist in the list.
|
|
||||||
*/
|
|
||||||
fun remove(path: Path) {
|
|
||||||
L.d("Removing $path")
|
|
||||||
val idx = _dirs.indexOf(path)
|
|
||||||
_dirs.removeAt(idx)
|
|
||||||
notifyItemRemoved(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Listener for [DirectoryAdapter] interactions. */
|
|
||||||
interface Listener {
|
|
||||||
/** Called when the delete button on a directory item is clicked. */
|
|
||||||
fun onRemoveDirectory(dir: Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [RecyclerView.Recycler] that displays a [Path]. Use [from] to create an instance.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
|
|
||||||
DialogRecyclerView.ViewHolder(binding.root) {
|
|
||||||
/**
|
|
||||||
* Bind new data to this instance.
|
|
||||||
*
|
|
||||||
* @param path The new [Path] to bind.
|
|
||||||
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
|
|
||||||
*/
|
|
||||||
fun bind(path: Path, listener: DirectoryAdapter.Listener) {
|
|
||||||
binding.dirPath.text = path.resolve(binding.context)
|
|
||||||
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(path) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a new instance.
|
|
||||||
*
|
|
||||||
* @param parent The parent to inflate this instance from.
|
|
||||||
* @return A new instance.
|
|
||||||
*/
|
|
||||||
fun from(parent: View) =
|
|
||||||
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,188 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 Auxio Project
|
|
||||||
* MusicDirsDialog.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.dirs
|
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import javax.inject.Inject
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
|
||||||
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
|
||||||
import org.oxycblt.auxio.music.fs.Path
|
|
||||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
|
||||||
import org.oxycblt.auxio.util.showToast
|
|
||||||
import timber.log.Timber as L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog that manages the music dirs setting.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MusicDirsDialog :
|
|
||||||
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
|
||||||
private val dirAdapter = DirectoryAdapter(this)
|
|
||||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
|
||||||
@Inject lateinit var documentPathFactory: DocumentPathFactory
|
|
||||||
@Inject lateinit var musicSettings: MusicSettings
|
|
||||||
|
|
||||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
|
||||||
DialogMusicDirsBinding.inflate(inflater)
|
|
||||||
|
|
||||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
|
||||||
builder
|
|
||||||
.setTitle(R.string.set_dirs)
|
|
||||||
.setNegativeButton(R.string.lbl_cancel, null)
|
|
||||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
|
||||||
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
|
||||||
if (musicSettings.musicDirs != newDirs) {
|
|
||||||
L.d("Committing changes")
|
|
||||||
musicSettings.musicDirs = newDirs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
|
|
||||||
openDocumentTreeLauncher =
|
|
||||||
registerForActivityResult(
|
|
||||||
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
|
||||||
|
|
||||||
binding.dirsAdd.apply {
|
|
||||||
ViewCompat.setTooltipText(this, contentDescription)
|
|
||||||
setOnClickListener {
|
|
||||||
L.d("Opening launcher")
|
|
||||||
val launcher =
|
|
||||||
requireNotNull(openDocumentTreeLauncher) {
|
|
||||||
"Document tree launcher was not available"
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
launcher.launch(null)
|
|
||||||
} catch (e: ActivityNotFoundException) {
|
|
||||||
// User doesn't have a capable file manager.
|
|
||||||
requireContext().showToast(R.string.err_no_app)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.dirsRecycler.apply {
|
|
||||||
adapter = dirAdapter
|
|
||||||
itemAnimator = null
|
|
||||||
}
|
|
||||||
|
|
||||||
var dirs = musicSettings.musicDirs
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
|
||||||
if (pendingDirs != null) {
|
|
||||||
dirs =
|
|
||||||
MusicDirectories(
|
|
||||||
pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
|
|
||||||
savedInstanceState.getBoolean(KEY_PENDING_MODE))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dirAdapter.addAll(dirs.dirs)
|
|
||||||
requireBinding().dirsEmpty.isVisible = dirs.dirs.isEmpty()
|
|
||||||
|
|
||||||
binding.folderModeGroup.apply {
|
|
||||||
check(
|
|
||||||
if (dirs.shouldInclude) {
|
|
||||||
R.id.dirs_mode_include
|
|
||||||
} else {
|
|
||||||
R.id.dirs_mode_exclude
|
|
||||||
})
|
|
||||||
|
|
||||||
updateMode()
|
|
||||||
addOnButtonCheckedListener { _, _, _ -> updateMode() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
outState.putStringArrayList(
|
|
||||||
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
|
|
||||||
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
|
|
||||||
super.onDestroyBinding(binding)
|
|
||||||
openDocumentTreeLauncher = null
|
|
||||||
binding.dirsRecycler.adapter = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRemoveDirectory(dir: Path) {
|
|
||||||
dirAdapter.remove(dir)
|
|
||||||
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a Document Tree [Uri] chosen by the user to the current [MusicDirectories] instance.
|
|
||||||
*
|
|
||||||
* @param uri The document tree [Uri] to add, chosen by the user. Will do nothing if the [Uri]
|
|
||||||
* is null or not valid.
|
|
||||||
*/
|
|
||||||
private fun addDocumentTreeUriToDirs(uri: Uri?) {
|
|
||||||
if (uri == null) {
|
|
||||||
// A null URI means that the user left the file picker without picking a directory
|
|
||||||
L.d("No URI given (user closed the dialog)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val dir = documentPathFactory.unpackDocumentTreeUri(uri)
|
|
||||||
|
|
||||||
if (dir != null) {
|
|
||||||
dirAdapter.add(dir)
|
|
||||||
requireBinding().dirsEmpty.isVisible = false
|
|
||||||
} else {
|
|
||||||
requireContext().showToast(R.string.err_bad_dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMode() {
|
|
||||||
val binding = requireBinding()
|
|
||||||
if (isUiModeInclude(binding)) {
|
|
||||||
binding.dirsModeExclude.icon = null
|
|
||||||
binding.dirsModeInclude.setIconResource(R.drawable.ic_check_24)
|
|
||||||
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
|
|
||||||
} else {
|
|
||||||
binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24)
|
|
||||||
binding.dirsModeInclude.icon = null
|
|
||||||
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get if the UI has currently configured [MusicDirectories.shouldInclude] to be true. */
|
|
||||||
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
|
|
||||||
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
|
|
||||||
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2023 Auxio Project
|
|
||||||
* FsModule.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.fs
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.storage.StorageManager
|
|
||||||
import dagger.Binds
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
class FsModule {
|
|
||||||
@Provides
|
|
||||||
fun volumeManager(@ApplicationContext context: Context): VolumeManager =
|
|
||||||
VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class))
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun mediaStoreExtractor(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
|
|
||||||
) = MediaStoreExtractor.from(context, mediaStorePathInterpreterFactory)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun mediaStorePathInterpreterFactory(
|
|
||||||
volumeManager: VolumeManager
|
|
||||||
): MediaStorePathInterpreter.Factory = MediaStorePathInterpreter.Factory.from(volumeManager)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
|
||||||
context.contentResolverSafe
|
|
||||||
}
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface FsBindsModule {
|
|
||||||
@Binds
|
|
||||||
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue