Compare commits
623 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1beeab28a6 | ||
|
|
ad6a569961 | ||
|
|
0f5a8f6b97 | ||
|
|
3cd1bdf229 | ||
|
|
d7389db265 | ||
|
|
b3c93b3885 | ||
|
|
25864accc9 | ||
|
|
f734ced2cb | ||
|
|
4a3d2305c0 | ||
|
|
8db6797eaa | ||
|
|
cb4c19757d | ||
|
|
b6e75afe12 | ||
|
|
b621be06df | ||
|
|
7a17e91690 | ||
|
|
1036829186 | ||
|
|
becfc1d589 | ||
|
|
44bf346332 | ||
|
|
896e5fb3bd | ||
|
|
3086a8b9f9 | ||
|
|
10c2172be0 | ||
|
|
918bf6928e | ||
|
|
c9cf86acb5 | ||
|
|
0487f3bb9b | ||
|
|
c7f2524085 | ||
|
|
88c2129cd4 | ||
|
|
aa5d0f92db | ||
|
|
3ba2255205 | ||
|
|
145bb82eb0 | ||
|
|
932d1aaa8c | ||
|
|
4f8212d491 | ||
|
|
b403d69982 | ||
|
|
a49f2b97a2 | ||
|
|
c44e60c0e5 | ||
|
|
4cd15b4284 | ||
|
|
72d7aea6e3 | ||
|
|
9adaf8c013 | ||
|
|
661346ca3a | ||
|
|
dbd32baa12 | ||
|
|
3958cbcc1c | ||
|
|
fb568d1d74 | ||
|
|
e06a168350 | ||
|
|
b8dc985279 | ||
|
|
090701b92b | ||
|
|
7767a66fb8 | ||
|
|
d1122bef4e | ||
|
|
72d4495582 | ||
|
|
499644d041 | ||
|
|
21ed78d959 | ||
|
|
5ad99b9f27 | ||
|
|
3de5390140 | ||
|
|
d215581e19 | ||
|
|
54612c6b74 | ||
|
|
eaac728a26 | ||
|
|
65d2f8e33f | ||
|
|
baf4e0f0fc | ||
|
|
26c7bee106 | ||
|
|
6e51611867 | ||
|
|
d67e432731 | ||
|
|
8b61396b0f | ||
|
|
0fb6e55b12 | ||
|
|
dd7aa2291b | ||
|
|
ec33c32c89 | ||
|
|
e0ad4e3701 | ||
|
|
253f8033c5 | ||
|
|
c1aed1a4c1 | ||
|
|
23f58439ba | ||
|
|
4c99ced597 | ||
|
|
8d215a7f1c | ||
|
|
38fc4a0936 | ||
|
|
d9949349da | ||
|
|
877d29d285 | ||
|
|
9a17aa8b98 | ||
|
|
fd41395ab8 | ||
|
|
269066e036 | ||
|
|
488460ea9d | ||
|
|
d16a9c234f | ||
|
|
07b507691c | ||
|
|
bde34d3df0 | ||
|
|
e5b7756f96 | ||
|
|
04e692e5e9 | ||
|
|
a23a663d32 | ||
|
|
023bd8071a | ||
|
|
72b1517f61 | ||
|
|
e62ea72c2f | ||
|
|
a24ccf2556 | ||
|
|
49838e2e0f | ||
|
|
8ed1248ee1 | ||
|
|
e9d54957ae | ||
|
|
3cd5843c4b | ||
|
|
75513d3bd4 | ||
|
|
c0c84269ef | ||
|
|
fa2e029f9f | ||
|
|
f1bfb095b7 | ||
|
|
4328415efc | ||
|
|
092ae14ea2 | ||
|
|
26af8a692f | ||
|
|
b870f4c866 | ||
|
|
cf4e78eafc | ||
|
|
83e23c44d9 | ||
|
|
c0959c7ca4 | ||
|
|
e77f3bf9b3 | ||
|
|
55265615e6 | ||
|
|
bd872fc23d | ||
|
|
64a1966ad8 | ||
|
|
5ef5731fe3 | ||
|
|
c5cece8477 | ||
|
|
bae9221070 | ||
|
|
c0dbe01bf9 | ||
|
|
5f550b0df4 | ||
|
|
6100c3e7f1 | ||
|
|
f01ca9fed0 | ||
|
|
d232ebfa6f | ||
|
|
53ca88989f | ||
|
|
a82cf70433 | ||
|
|
89aa18b5f0 | ||
|
|
431014adc4 | ||
|
|
6110a9c8e7 | ||
|
|
993374e56c | ||
|
|
a2801f3168 | ||
|
|
99c31f4318 | ||
|
|
05785979e3 | ||
|
|
586a1a160e | ||
|
|
d04ed8d430 | ||
|
|
193447d07e | ||
|
|
1725b0de2e | ||
|
|
a2401302ed | ||
|
|
f39891dd2c | ||
|
|
8c5390bfef | ||
|
|
10673a49d4 | ||
|
|
3ce34fb874 | ||
|
|
5c94e9122c | ||
|
|
8140e80d61 | ||
|
|
c1b2ec09a4 | ||
|
|
3b3f55c5de | ||
|
|
17020e5192 | ||
|
|
f22aea7b1d | ||
|
|
844b57054b | ||
|
|
8de9aff1f6 | ||
|
|
f59f572e5c | ||
|
|
da2221540e | ||
|
|
9fa29c183a | ||
|
|
d034171d92 | ||
|
|
3a30b3d379 | ||
|
|
2624f396e5 | ||
|
|
8ae32a3a22 | ||
|
|
3c1975f6bf | ||
|
|
43a96faca4 | ||
|
|
bbd6d0864c | ||
|
|
ccea7674bd | ||
|
|
7f332c26ad | ||
|
|
206a7f38ca | ||
|
|
16e0a5e12e | ||
|
|
c6896939e2 | ||
|
|
526253723b | ||
|
|
9350a9cc2e | ||
|
|
e2ec2e4602 | ||
|
|
bca2e8fcae | ||
|
|
43674ea1f9 | ||
|
|
373a1f87a1 | ||
|
|
e14a595fba | ||
|
|
727e137008 | ||
|
|
883d853129 | ||
|
|
0d329aff64 | ||
|
|
94cb6fa279 | ||
|
|
257d80ecac | ||
|
|
d0f77fe0fc | ||
|
|
e95b504dbb | ||
|
|
0b68799507 | ||
|
|
9167be2cf2 | ||
|
|
d426c08cdd | ||
|
|
972c32b9d8 | ||
|
|
a279e20a49 | ||
|
|
fe60fea928 | ||
|
|
c6df43da9c | ||
|
|
475ed3e7c8 | ||
|
|
fb4c762655 | ||
|
|
a110faabe3 | ||
|
|
df2bf43492 | ||
|
|
b46fea6890 | ||
|
|
213a0d5293 | ||
|
|
08b6379601 | ||
|
|
3fbadc2521 | ||
|
|
9e78caeda4 | ||
|
|
e072a49288 | ||
|
|
b89e18eebf | ||
|
|
63607794d6 | ||
|
|
37842fd897 | ||
|
|
a1397a224b | ||
|
|
804d6af6c3 | ||
|
|
e315169005 | ||
|
|
ea76afee09 | ||
|
|
45dda3af9b | ||
|
|
3d70b51244 | ||
|
|
22f196c8c0 | ||
|
|
540aa9ba73 | ||
|
|
1ff0b83a19 | ||
|
|
27f5a47cc9 | ||
|
|
732b6ad09d | ||
|
|
0df7346a14 | ||
|
|
786697109d | ||
|
|
1bfadb0669 | ||
|
|
79dc1cc93b | ||
|
|
38fb2c69f1 | ||
|
|
b34f827bc0 | ||
|
|
97d1b408e1 | ||
|
|
a5065578ca | ||
|
|
aac5c6067d | ||
|
|
cfd7cf314b | ||
|
|
c4b73f6014 | ||
|
|
35d377ce31 | ||
|
|
5e330ac451 | ||
|
|
8188ef169c | ||
|
|
3496918ce6 | ||
|
|
c72f368f6a | ||
|
|
eb089847e0 | ||
|
|
8aaa6b207e | ||
|
|
72d560e4eb | ||
|
|
31219ea754 | ||
|
|
52c411ead0 | ||
|
|
0edbd15d47 | ||
|
|
342241963a | ||
|
|
f5b381eb35 | ||
|
|
be33401b6f | ||
|
|
c415db0cc5 | ||
|
|
35576c3d6f | ||
|
|
a11fbfa829 | ||
|
|
26d1b144e4 | ||
|
|
16b63bf13c | ||
|
|
52434f3aa9 | ||
|
|
7aa325f914 | ||
|
|
5a8a631449 | ||
|
|
e6bbd7b2bf | ||
|
|
3721484dff | ||
|
|
6698052ba5 | ||
|
|
f6f24acfdf | ||
|
|
ca8bcba0d7 | ||
|
|
9a36f8541f | ||
|
|
0026dc287f | ||
|
|
c65077172d | ||
|
|
6e6c261f35 | ||
|
|
130cbbd7dd | ||
|
|
63668f5a8c | ||
|
|
193b551773 | ||
|
|
76a0e12222 | ||
|
|
887d8c85ee | ||
|
|
6a90f06084 | ||
|
|
f7a21cbb52 | ||
|
|
3c6c240b9d | ||
|
|
2553c06a9f | ||
|
|
62a10d142e | ||
|
|
955dc1b015 | ||
|
|
6124ec66f3 | ||
|
|
2c6287405e | ||
|
|
0be309fb22 | ||
|
|
a79543569d | ||
|
|
e3d7120193 | ||
|
|
80a3a54476 | ||
|
|
6448cc598d | ||
|
|
c4bd30d512 | ||
|
|
b2e3596d87 | ||
|
|
ccfe74a6ea | ||
|
|
f4ffdc985e | ||
|
|
33981f9885 | ||
|
|
1bc93cce0e | ||
|
|
ec0eee9d3f | ||
|
|
140546ca4d | ||
|
|
fd075a02c5 | ||
|
|
4d1d953a3a | ||
|
|
9dd509be22 | ||
|
|
efaae35976 | ||
|
|
35784216bc | ||
|
|
8ce0a82506 | ||
|
|
51883cd82b | ||
|
|
829c5d85f6 | ||
|
|
748a19ef44 | ||
|
|
e987226954 | ||
|
|
59e2f4a7fa | ||
|
|
817b53efaa | ||
|
|
12c7ec86a9 | ||
|
|
611b5001be | ||
|
|
923cfd5bc9 | ||
|
|
36d2320e70 | ||
|
|
17713ee400 | ||
|
|
ee3465868e | ||
|
|
9e8141a8d9 | ||
|
|
043e1b39b0 | ||
|
|
938c1de906 | ||
|
|
a0dfe63660 | ||
|
|
42b7441467 | ||
|
|
28c2f87b26 | ||
|
|
9ab22bfede | ||
|
|
20900fb557 | ||
|
|
7457c5b6e3 | ||
|
|
e5a928ec0f | ||
|
|
147c8360a6 | ||
|
|
d5d504fc64 | ||
|
|
24eead2d0a | ||
|
|
2644fa52b6 | ||
|
|
38c144c073 | ||
|
|
1dca1ef68d | ||
|
|
ba94d7e5cc | ||
|
|
0028872e3f | ||
|
|
be9eec625a | ||
|
|
b335ddec01 | ||
|
|
8ad35ce83a | ||
|
|
eb5c4721d1 | ||
|
|
b0e8fa75ca | ||
|
|
27d7288ee9 | ||
|
|
287921de09 | ||
|
|
e5cb8793b0 | ||
|
|
f25e7f250a | ||
|
|
911acc3c2d | ||
|
|
4b7f60bb8c | ||
|
|
d35146dba3 | ||
|
|
6c3897a400 | ||
|
|
1002499d92 | ||
|
|
4464b5b34d | ||
|
|
77c0b86dac | ||
|
|
0abdfc6b19 | ||
|
|
52b2ca8fa7 | ||
|
|
7f66124614 | ||
|
|
9930537486 | ||
|
|
5d51132921 | ||
|
|
4b2e963a81 | ||
|
|
4c865e199d | ||
|
|
fc58869354 | ||
|
|
5e1a2b41e9 | ||
|
|
77bdd71d79 | ||
|
|
7267c13ee0 | ||
|
|
4ab1f034d8 | ||
|
|
4bd8bbfa4c | ||
|
|
3fc03114e2 | ||
|
|
576c93e6cb | ||
|
|
ac674d937a | ||
|
|
0ed329022e | ||
|
|
b8b4a77349 | ||
|
|
de14663b25 | ||
|
|
747af0d81c | ||
|
|
c95e7cc5e0 | ||
|
|
0c3b43c5dc | ||
|
|
830e9076f1 | ||
|
|
cd9ae97bc7 | ||
|
|
1b59a8e8ef | ||
|
|
391405fc76 | ||
|
|
4bdcbacf62 | ||
|
|
6cbae700bf | ||
|
|
e7555119f0 | ||
|
|
e228e74e6a | ||
|
|
062f4db2cf | ||
|
|
cb75e34b92 | ||
|
|
36005c5f51 | ||
|
|
8ae0900269 | ||
|
|
f286c7b1b9 | ||
|
|
c5d0af67a7 | ||
|
|
3fb4ccd791 | ||
|
|
577b50a85b | ||
|
|
e6a56ba1d2 | ||
|
|
2740b6da29 | ||
|
|
21c4ae77ba | ||
|
|
7a83a03a90 | ||
|
|
be0480538e | ||
|
|
b48057b4a2 | ||
|
|
fa430eaac4 | ||
|
|
82ee9b4639 | ||
|
|
9b807fde31 | ||
|
|
c7ba4235b3 | ||
|
|
d27e431f73 | ||
|
|
d23eea4f27 | ||
|
|
430e7105eb | ||
|
|
024c4e6118 | ||
|
|
7b2ee9da3a | ||
|
|
c3cce18600 | ||
|
|
442fe1ea01 | ||
|
|
cb0874dca4 | ||
|
|
079149c1d5 | ||
|
|
118f742cb6 | ||
|
|
c028c52576 | ||
|
|
96c5d0fca8 | ||
|
|
e39a5e2d5c | ||
|
|
a06ab77b42 | ||
|
|
04a6176bfd | ||
|
|
1f4464e089 | ||
|
|
9d01d2057a | ||
|
|
ad440c490a | ||
|
|
acdcfff9ac | ||
|
|
8c7a25cbd0 | ||
|
|
bdca5e16ed | ||
|
|
f091b3d248 | ||
|
|
18cd84f820 | ||
|
|
281ebf8263 | ||
|
|
2854ac6354 | ||
|
|
16d25a1f1d | ||
|
|
5d3ca8acfa | ||
|
|
0689272046 | ||
|
|
17372fc4d0 | ||
|
|
44679855cd | ||
|
|
78e7032903 | ||
|
|
8d8087f2d6 | ||
|
|
5b6a4fab62 | ||
|
|
fdc41b299c | ||
|
|
82c22ed247 | ||
|
|
48ce3a2a4f | ||
|
|
b93acc6563 | ||
|
|
9c088a7e88 | ||
|
|
de2f1067a7 | ||
|
|
1c21546461 | ||
|
|
a4121e8d49 | ||
|
|
ceaffa254b | ||
|
|
4cc4cc7363 | ||
|
|
c5ef274916 | ||
|
|
2c53f36a18 | ||
|
|
6c637dcbcb | ||
|
|
89fa38f5a0 | ||
|
|
87f8bdc618 | ||
|
|
903fde4bdc | ||
|
|
3824dd882c | ||
|
|
602bab6414 | ||
|
|
d891e429b6 | ||
|
|
ebefd77027 | ||
|
|
57f34affd9 | ||
|
|
50b5ab38bc | ||
|
|
9f61d70fca | ||
|
|
a97a2d5b50 | ||
|
|
8d73a2cd36 | ||
|
|
ca5a0698bb | ||
|
|
04f34e03d1 | ||
|
|
233bc9987e | ||
|
|
f0e418687e | ||
|
|
e87b658447 | ||
|
|
19c985c9e4 | ||
|
|
4ab122a9d7 | ||
|
|
ff0c42d14c | ||
|
|
d1e247f9e2 | ||
|
|
f1d19142fa | ||
|
|
45793c343a | ||
|
|
aa4249842d | ||
|
|
126663f1e5 | ||
|
|
ec19e8c401 | ||
|
|
717f95a04a | ||
|
|
ccce01a61b | ||
|
|
a7682d7656 | ||
|
|
84de93a4f1 | ||
|
|
78c4c89eca | ||
|
|
328beaff90 | ||
|
|
9d5d89d648 | ||
|
|
cd8b06f544 | ||
|
|
47a0def06c | ||
|
|
1c2f1aa061 | ||
|
|
30281e8f2d | ||
|
|
3c58e6fbb2 | ||
|
|
99a399b4d7 | ||
|
|
1da0a0b810 | ||
|
|
539920965e | ||
|
|
9a64eeabe6 | ||
|
|
791190f681 | ||
|
|
c03fca8039 | ||
|
|
620fba0a14 | ||
|
|
1357c5c062 | ||
|
|
682f63ef38 | ||
|
|
24864637f9 | ||
|
|
3ba19be4d9 | ||
|
|
cce6456951 | ||
|
|
fda586c4d8 | ||
|
|
57be72d5d4 | ||
|
|
c2354d4d42 | ||
|
|
a940af934c | ||
|
|
5891ec800c | ||
|
|
7259a82b67 | ||
|
|
c2b6d7eed5 | ||
|
|
2335bf2095 | ||
|
|
8bb6c02e46 | ||
|
|
47380a79a5 | ||
|
|
a187ba1e75 | ||
|
|
3eb9b2fb5c | ||
|
|
a547e19361 | ||
|
|
f4722fa0a8 | ||
|
|
ee738bc4c7 | ||
|
|
a22883fdde | ||
|
|
2acf11023a | ||
|
|
9736890e3c | ||
|
|
e790bf3eb6 | ||
|
|
e1d63a9eef | ||
|
|
134a1605ad | ||
|
|
1b45036963 | ||
|
|
0ba12c3d84 | ||
|
|
8cc3356b14 | ||
|
|
9d439b726b | ||
|
|
d4c0e30fd1 | ||
|
|
bb23d7e866 | ||
|
|
7321ef46f2 | ||
|
|
2fe2c2b28b | ||
|
|
a9318ec5d0 | ||
|
|
eb29dc2fb2 | ||
|
|
287e4a2b10 | ||
|
|
b7d56c2d70 | ||
|
|
5a6d101bdf | ||
|
|
969f0b5b21 | ||
|
|
14939d20fd | ||
|
|
35af1f9038 | ||
|
|
b79cfa4af0 | ||
|
|
e81e1a5356 | ||
|
|
cc0e264a17 | ||
|
|
a83495f353 | ||
|
|
be4346b3d1 | ||
|
|
2e29e9537a | ||
|
|
bc0adfe8e0 | ||
|
|
5261ca317b | ||
|
|
bf4ff3f1f9 | ||
|
|
cd195dbba0 | ||
|
|
7ec78991a5 | ||
|
|
e1c5a60805 | ||
|
|
f74813ef69 | ||
|
|
040558198e | ||
|
|
5ab68e4a98 | ||
|
|
aa8fac43a6 | ||
|
|
905bb3e3c5 | ||
|
|
d810010090 | ||
|
|
52ba783a90 | ||
|
|
d72855e160 | ||
|
|
82a9f00173 | ||
|
|
3f5749f7e1 | ||
|
|
cd2ab36351 | ||
|
|
a6688f897a | ||
|
|
64658dda1f | ||
|
|
d9f701d9d3 | ||
|
|
60fee3c77c | ||
|
|
b89086c5be | ||
|
|
d3dd236054 | ||
|
|
2e3330b63f | ||
|
|
e604c9ba86 | ||
|
|
2bf39d846e | ||
|
|
06066f1f66 | ||
|
|
7c0d44680f | ||
|
|
d4cb6c5c9a | ||
|
|
fab18c130e | ||
|
|
ffcfd81c28 | ||
|
|
bd753f4489 | ||
|
|
e43a2b6fe5 | ||
|
|
c62d2ace4d | ||
|
|
6d403f808c | ||
|
|
1223062388 | ||
|
|
b0ddd5388b | ||
|
|
92f79a8e3d | ||
|
|
473d7e4e9c | ||
|
|
7ca0415274 | ||
|
|
cf7feacdc0 | ||
|
|
4740028a44 | ||
|
|
fe2c163aaa | ||
|
|
c6f08d9cec | ||
|
|
59b40df9ef | ||
|
|
81726baa08 | ||
|
|
af92a7b11a | ||
|
|
0578745bee | ||
|
|
e24063e460 | ||
|
|
4f1b1b603e | ||
|
|
7279c62944 | ||
|
|
a59d46f884 | ||
|
|
10285b308d | ||
|
|
8e2c5d1fee | ||
|
|
f59a360eb7 | ||
|
|
accf5fddc2 | ||
|
|
cc61d1cd48 | ||
|
|
6a16159cf0 | ||
|
|
eaf2710054 | ||
|
|
31d91f7215 | ||
|
|
f854f49686 | ||
|
|
9e8870a86a | ||
|
|
a0040c52a0 | ||
|
|
65f6347faf | ||
|
|
85fa2f768e | ||
|
|
cc5abd150a | ||
|
|
1ed6ac6cff | ||
|
|
cc6cb077b4 | ||
|
|
4be0acf76c | ||
|
|
7d843390db | ||
|
|
5c5316055c | ||
|
|
f1a179e7f8 | ||
|
|
0377c5e939 | ||
|
|
614ce8b466 | ||
|
|
9e87b53bc9 | ||
|
|
08023026b4 | ||
|
|
1bbcf6c790 | ||
|
|
698ca3b22b | ||
|
|
b2d875ac98 | ||
|
|
02eef97171 | ||
|
|
26a5fb029a | ||
|
|
c38c7c3deb | ||
|
|
8ed0a4642b | ||
|
|
6cfa04d368 | ||
|
|
af98f8d1a9 | ||
|
|
469204daac | ||
|
|
8943faf44c | ||
|
|
fea6366d84 | ||
|
|
92ac2e5684 | ||
|
|
06a52afa18 | ||
|
|
87f6db9e79 | ||
|
|
5fa46cc49b | ||
|
|
4da967910a | ||
|
|
3b18f39948 | ||
|
|
c9e0581815 | ||
|
|
b0fcc31f7b | ||
|
|
e98c9483c8 | ||
|
|
98a45b6059 | ||
|
|
6e070dfef0 | ||
|
|
910cce90f5 | ||
|
|
1a70ccd8f4 | ||
|
|
7830657fe1 | ||
|
|
0351ccfc95 | ||
|
|
10af6fb4ce | ||
|
|
61ec15e696 | ||
|
|
d21bd475a1 | ||
|
|
7e34f6ee64 | ||
|
|
7cfefe76cc | ||
|
|
4585533740 | ||
|
|
07c1760c39 | ||
|
|
24d4e67872 | ||
|
|
bec840620c | ||
|
|
2fa4ddf874 | ||
|
|
e16f88cb73 | ||
|
|
f79b05cb67 | ||
|
|
ed7c572578 | ||
|
|
e891214831 | ||
|
|
3d5ef1d900 |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: eddyizm
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -26,6 +26,7 @@ Outline the steps required to reproduce the bug, including any specific actions,
|
||||
- Android device: [Device Model]
|
||||
- Android OS version: [Android Version]
|
||||
- App version: [App Version]
|
||||
- App variant: [goole play services, degoogled]
|
||||
- Other relevant details: [e.g., specific network conditions, external dependencies]
|
||||
|
||||
## Logs or Screenshots
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/crash-report.md
vendored
3
.github/ISSUE_TEMPLATE/crash-report.md
vendored
@@ -23,10 +23,11 @@ Please provide the steps to reproduce the crash:
|
||||
- Android device: [Device Model]
|
||||
- Android OS version: [Android Version]
|
||||
- App version: [App Version]
|
||||
- App variant: [goole play services, degoogled]
|
||||
- Other relevant details: [e.g., specific network conditions, external dependencies]
|
||||
|
||||
## Crash Logs/Stack trace
|
||||
<!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempo-debug.apk), as the logs would be illegible and therefore useless for this purpose. -->
|
||||
<!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempus-debug.apk), as the logs would be illegible and therefore useless for this purpose. -->
|
||||
|
||||
## Screenshots
|
||||
<!-- If applicable, add screenshots to help explain the problem. -->
|
||||
|
||||
95
.github/workflows/github_release.yml
vendored
95
.github/workflows/github_release.yml
vendored
@@ -35,15 +35,21 @@ jobs:
|
||||
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- name: Build APK
|
||||
- name: Build All Release APKs
|
||||
id: build
|
||||
run: bash ./gradlew assembleTempoRelease
|
||||
run: |
|
||||
# Only build release variants (removed debug builds)
|
||||
bash ./gradlew assembleTempusRelease
|
||||
bash ./gradlew assembleDegoogledRelease
|
||||
|
||||
- name: Sign APK
|
||||
id: sign_apk
|
||||
- name: Create Artifact Staging Directory
|
||||
run: mkdir -p release-artifacts
|
||||
|
||||
- name: Sign Tempus Release APKs
|
||||
id: sign_tempus_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/tempo/release
|
||||
releaseDirectory: app/build/outputs/apk/tempus/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
@@ -51,28 +57,73 @@ jobs:
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Make artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Prepare Signed Tempus APKs for Release
|
||||
run: |
|
||||
TEMPUS_PATH=app/build/outputs/apk/tempus/release
|
||||
|
||||
echo "--- Tempus Files BEFORE Move ---"
|
||||
ls -la $TEMPUS_PATH
|
||||
echo "--------------------------------"
|
||||
|
||||
# FIX: Use find/xargs for robust file matching and moving.
|
||||
|
||||
# Renaming 64-bit APK and moving to safe staging directory
|
||||
find $TEMPUS_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-tempus-arm64-v8a-release.apk
|
||||
|
||||
# Renaming 32-bit APK and moving to safe staging directory
|
||||
find $TEMPUS_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-tempus-armeabi-v7a-release.apk
|
||||
|
||||
echo "Prepared Tempus APKs."
|
||||
|
||||
- name: Sign Degoogled Release APKs
|
||||
id: sign_degoogled_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
name: app-release-signed
|
||||
path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
releaseDirectory: app/build/outputs/apk/degoogled/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Prepare Signed Degoogled APKs for Release
|
||||
run: |
|
||||
DEGOOGLED_PATH=app/build/outputs/apk/degoogled/release
|
||||
|
||||
echo "--- Degoogled Files BEFORE Move ---"
|
||||
ls -la $DEGOOGLED_PATH
|
||||
echo "--------------------------------"
|
||||
|
||||
# FIX: Use find/xargs for robust file matching and moving.
|
||||
|
||||
# Renaming 64-bit APK and moving to safe staging directory
|
||||
find $DEGOOGLED_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-degoogled-arm64-v8a-release.apk
|
||||
|
||||
# Renaming 32-bit APK and moving to safe staging directory
|
||||
find $DEGOOGLED_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-degoogled-armeabi-v7a-release.apk
|
||||
|
||||
echo "Prepared Degoogled APKs."
|
||||
ls -la ./release-artifacts/
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release v${{ github.ref }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
body: '> Changelog coming soon'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: ./release-artifacts/*.apk
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- name: Upload Release APKs as artifacts (For easy pipeline access)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
asset_name: app-tempo-release.apk
|
||||
asset_content_type: application/zip
|
||||
name: release-apks
|
||||
path: ./release-artifacts/*.apk
|
||||
retention-days: 30
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -14,4 +14,8 @@
|
||||
.cxx
|
||||
/.idea/
|
||||
.env
|
||||
.vscode/settings.json
|
||||
.vscode/settings.json
|
||||
# release / debug files
|
||||
tempus-release-key.jks
|
||||
app/tempus/
|
||||
app/degoogled/
|
||||
|
||||
434
CHANGELOG.md
434
CHANGELOG.md
@@ -1,11 +1,437 @@
|
||||
# Changelog
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
## What's Changed
|
||||
## [4.13.0](https://github.com/eddyizm/tempo/releases/tag/v4.13.0) (2026-03-25)
|
||||
* chore(i18n): Improve Russian translation by @NikkoFox in https://github.com/eddyizm/tempus/pull/503
|
||||
* feat: tile size manager by @MaFo-28 in https://github.com/eddyizm/tempus/pull/440
|
||||
* chore(i18n): Translated to zh_TW by @olivertzeng in https://github.com/eddyizm/tempus/pull/494
|
||||
* fix: Show full album name when displaying details by @jaime-grj in https://github.com/eddyizm/tempus/pull/508
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/509
|
||||
* fix: Relocate "Offline mode" text by @jaime-grj in https://github.com/eddyizm/tempus/pull/510
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/516
|
||||
* refactor: navigation and bottom sheet by @tvillega in https://github.com/eddyizm/tempus/pull/491
|
||||
* feat: Logo refresh by @eddyizm in https://github.com/eddyizm/tempus/pull/498
|
||||
* feat: Add 'genres' page/function to Android Auto by @Jorilx in https://github.com/eddyizm/tempus/pull/505
|
||||
* feat: Added all-songs feature by @unknown0816 in https://github.com/eddyizm/tempus/pull/517
|
||||
|
||||
## [3.11.1](https://github.com/eddyizm/tempo/releases/tag/v3.11.1) (2025-08-09)
|
||||
## New Contributors
|
||||
* @NikkoFox made their first contribution in https://github.com/eddyizm/tempus/pull/503
|
||||
* @olivertzeng made their first contribution in https://github.com/eddyizm/tempus/pull/494
|
||||
* @Jorilx made their first contribution in https://github.com/eddyizm/tempus/pull/505
|
||||
* @unknown0816 made their first contribution in https://github.com/eddyizm/tempus/pull/517
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.6...v4.13.0
|
||||
|
||||
## What's Changed
|
||||
## [4.12.6](https://github.com/eddyizm/tempo/releases/tag/v4.12.6) (2026-03-06)
|
||||
* doc: update USAGE with android auto configuration by @MaFo-28 in https://github.com/eddyizm/tempus/pull/481
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/483
|
||||
* fix: remove material you dynamic theming by @tvillega in https://github.com/eddyizm/tempus/pull/484
|
||||
* fix: collapse sheet on navitation change by @tvillega in https://github.com/eddyizm/tempus/pull/482
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.4...v4.12.5
|
||||
|
||||
## What's Changed
|
||||
## [4.12.4](https://github.com/eddyizm/tempo/releases/tag/v4.12.4) (2026-03-01)
|
||||
* feat: advertise existing long press to refresh per section on library page by @tvillega in https://github.com/eddyizm/tempus/pull/467
|
||||
* fix: playlist filter returns properly filtered list and reset correctly by @eddyizm in https://github.com/eddyizm/tempus/pull/476
|
||||
* feat: toggle player bitrate visibility on touch by @tvillega in https://github.com/eddyizm/tempus/pull/466
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.0...v4.12.3
|
||||
|
||||
## What's Changed
|
||||
## [4.12.0](https://github.com/eddyizm/tempo/releases/tag/v4.12.0) (2026-02-28)
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/441
|
||||
* feat: radio logos support for AndroidAuto by @dmachard in https://github.com/eddyizm/tempus/pull/435
|
||||
* feat: Port remove song of playlist from tempus ng by @tvillega in https://github.com/eddyizm/tempus/pull/457
|
||||
* fix: artist sort by name case sensitive by @tvillega in https://github.com/eddyizm/tempus/pull/462
|
||||
* feat: added slide out enhanced navigation for tab mode and optionally portrait mode by @tvillega in https://github.com/eddyizm/tempus/pull/450
|
||||
* feat: Android Auto: improve media service browsing by @MaFo-28 in https://github.com/eddyizm/tempus/pull/437
|
||||
* feat: Support specifying a client certificate for mTLS auth by @tinsukE in https://github.com/eddyizm/tempus/pull/458
|
||||
|
||||
## New Contributors
|
||||
* @MaFo-28 made their first contribution in https://github.com/eddyizm/tempus/pull/437
|
||||
* @tinsukE made their first contribution in https://github.com/eddyizm/tempus/pull/458
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.11.0...v4.12.0
|
||||
|
||||
## What's Changed
|
||||
## [4.11.0](https://github.com/eddyizm/tempo/releases/tag/v4.11.0) (2026-02-15)
|
||||
* fix: added dynamic application id from gradle variant by @eddyizm in https://github.com/eddyizm/tempus/pull/425
|
||||
* fix: Use Bluetooth tethering connection by @jaime-grj in https://github.com/eddyizm/tempus/pull/428
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/427
|
||||
* fix: visual glitches on landscape navbar by @tvillega in https://github.com/eddyizm/tempus/pull/429
|
||||
* fix: radio playback "source error" on android auto by @dmachard in https://github.com/eddyizm/tempus/pull/426
|
||||
* fix: speed button overlaps with shuffle on landscape by @tvillega in https://github.com/eddyizm/tempus/pull/430
|
||||
* fix: local url used in share link instead of server url by @tvillega in https://github.com/eddyizm/tempus/pull/431
|
||||
* Feat :prefer downloaded files by @eddyizm in https://github.com/eddyizm/tempus/pull/433
|
||||
* fix: radio metadata displayed by @TrackArcher in https://github.com/eddyizm/tempus/pull/352
|
||||
* feat: improve playlist chooser dialog UI by @tvillega in https://github.com/eddyizm/tempus/pull/439
|
||||
|
||||
## New Contributors
|
||||
* @dmachard made their first contribution in https://github.com/eddyizm/tempus/pull/426
|
||||
* @TrackArcher made their first contribution in https://github.com/eddyizm/tempus/pull/352
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.10.1...v4.11.0
|
||||
|
||||
## What's Changed
|
||||
## [4.10.1](https://github.com/eddyizm/tempo/releases/tag/v4.10.1) (2026-02-08)
|
||||
* fix: Addressing some UI/UX quirks by @tiltshiftfocus in https://github.com/eddyizm/tempus/pull/413
|
||||
* fix: keep observer until data is received on continuousPlay bug by @eddyizm in https://github.com/eddyizm/tempus/pull/421
|
||||
* fix: album art now displays on android auto by @trobinson in https://github.com/eddyizm/tempus/pull/414
|
||||
* feat: improve landscape view and increase items per row on landscape view by @tvillega in https://github.com/eddyizm/tempus/pull/411
|
||||
|
||||
## New Contributors
|
||||
* @tiltshiftfocus made their first contribution in https://github.com/eddyizm/tempus/pull/413
|
||||
* @trobinson made their first contribution in https://github.com/eddyizm/tempus/pull/414
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.8...v4.10.1
|
||||
|
||||
## What's Changed
|
||||
## [4.9.8](https://github.com/eddyizm/tempo/releases/tag/v4.9.8) (2026-02-02)
|
||||
* fix: missing Replay Gain metadata from .m4a files by @pgrit in https://github.com/eddyizm/tempus/pull/396
|
||||
* fix: Improve Synced Lyrics by @pgrit in https://github.com/eddyizm/tempus/pull/384
|
||||
* fix: Add selector for playlist visibility by @tvillega in https://github.com/eddyizm/tempus/pull/394
|
||||
* chore(i18n): set links as untranslatable by @tvillega in https://github.com/eddyizm/tempus/pull/400
|
||||
|
||||
## New Contributors
|
||||
* @tvillega made their first contribution in https://github.com/eddyizm/tempus/pull/394
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.5...v4.5.8
|
||||
|
||||
## What's Changed
|
||||
## [4.9.5](https://github.com/eddyizm/tempo/releases/tag/v4.9.5) (2026-01-26)
|
||||
* fix: Avoid crash when server has no songs by @jaime-grj in https://github.com/eddyizm/tempus/pull/389
|
||||
* fix: updated dialog import to address crashing on android 15 by @eddyizm in https://github.com/eddyizm/tempus/pull/392
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.3...v4.9.5
|
||||
|
||||
## What's Changed
|
||||
## [4.9.3](https://github.com/eddyizm/tempo/releases/tag/v4.9.3) (2026-01-25)
|
||||
* fix: Proper raw stream detection by @jaime-grj in https://github.com/eddyizm/tempus/pull/382
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/381
|
||||
* feat: add configurable timeout by @eddyizm in https://github.com/eddyizm/tempus/pull/386
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.1...v4.9.3
|
||||
|
||||
## What's Changed
|
||||
## [4.9.1](https://github.com/eddyizm/tempo/releases/tag/v4.9.1) (2026-01-24)
|
||||
* chore: i18n: Add Romanian translation (including locale_config this time!) by @DevMatei in https://github.com/eddyizm/tempus/pull/357
|
||||
* French localization update by @benoit-smith in https://github.com/eddyizm/tempus/pull/356
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/364
|
||||
* docs: updated readme and added known issues for airsonic work around by @eddyizm in https://github.com/eddyizm/tempus/pull/366
|
||||
* fix: toast for made for you click indication by @eddyizm in https://github.com/eddyizm/tempus/pull/365
|
||||
* fix: sort playlist view by @eddyizm in https://github.com/eddyizm/tempus/pull/368
|
||||
* feat: sort preference for playlists by @eddyizm in https://github.com/eddyizm/tempus/pull/370
|
||||
* fix: use existing future when adding tracks, dialed random album tracks off in instant mix by @eddyizm in https://github.com/eddyizm/tempus/pull/373
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/374
|
||||
* fix: Check for OpenSubsonic extensions also with password authentication by @pgrit in https://github.com/eddyizm/tempus/pull/375
|
||||
* feat: Implement duration and seeking for transcodes by @drakeerv in https://github.com/eddyizm/tempus/pull/358
|
||||
* feat: Playback speed controls for music by @pgrit in https://github.com/eddyizm/tempus/pull/376
|
||||
|
||||
## New Contributors
|
||||
* @pgrit made their first contribution in https://github.com/eddyizm/tempus/pull/375
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.4...v4.9.1
|
||||
|
||||
## What's Changed
|
||||
## [4.6.4](https://github.com/eddyizm/tempo/releases/tag/v4.6.4) (2026-01-13)
|
||||
* fix: instant mix random songs and broken continuous play by @eddyizm in https://github.com/eddyizm/tempus/pull/354
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.3...v4.6.4
|
||||
|
||||
## What's Changed
|
||||
## [4.6.3](https://github.com/eddyizm/tempo/releases/tag/v4.6.3) (2026-01-10)
|
||||
* fix: give user feedback when trying to add podcast/radio on unsupport… by @eddyizm in https://github.com/eddyizm/tempus/pull/328
|
||||
* docs: Clarify Android Auto enablement by @Forage in https://github.com/eddyizm/tempus/pull/336
|
||||
* fix: instant mix gets a big refactor, with cascading fallbacks to produce a larger queue by @eddyizm in https://github.com/eddyizm/tempus/pull/330
|
||||
* chore(i18n): add missing keys, update Chinese translation and alphabetize by @hongwei1203 in https://github.com/eddyizm/tempus/pull/332
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/339
|
||||
* feat: Ability to toggle visibility of artist biography by @kmarius in https://github.com/eddyizm/tempus/pull/338
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.0...v4.6.3
|
||||
|
||||
## [4.6.0](https://github.com/eddyizm/tempo/releases/tag/v4.6.0) (2025-12-22)
|
||||
## What's Changed
|
||||
* chore: Update description_empty_title in English and Polish by @tyren234 in https://github.com/eddyizm/tempus/pull/307
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/310
|
||||
* fix: checks preference and writes files externally, updates the ui by @eddyizm in https://github.com/eddyizm/tempus/pull/312
|
||||
* chore: Update description_empty_title in Italian by @pochopsp in https://github.com/eddyizm/tempus/pull/314
|
||||
* chore: Update description_empty_title in French and Spanish by @pochopsp in https://github.com/eddyizm/tempus/pull/315
|
||||
* feat: added regular playlist to home view by @eddyizm in https://github.com/eddyizm/tempus/pull/322
|
||||
|
||||
## New Contributors
|
||||
* @tyren234 made their first contribution in https://github.com/eddyizm/tempus/pull/307
|
||||
* @pochopsp made their first contribution in https://github.com/eddyizm/tempus/pull/314
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.5.0...v4.6.0
|
||||
|
||||
## [4.5.0](https://github.com/eddyizm/tempo/releases/tag/v4.5.0) (2025-12-12)
|
||||
## What's Changed
|
||||
* fix: updates starred syncing downloads to user defined directory by @eddyizm in https://github.com/eddyizm/tempus/pull/298
|
||||
* fix: handle empty albums and null mappings by @eddyizm in https://github.com/eddyizm/tempus/pull/301
|
||||
* feat: integrate sort recent searches chronologically by @J4mm3ris in https://github.com/eddyizm/tempus/pull/300
|
||||
* feat: add heart to artist/album pages, fixed artist cover art failing by @eddyizm in https://github.com/eddyizm/tempus/pull/303
|
||||
|
||||
## New Contributors
|
||||
* @J4mm3ris made their first contribution in https://github.com/eddyizm/tempus/pull/300
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.4.0...v4.5.0
|
||||
|
||||
## [4.4.0](https://github.com/eddyizm/tempo/releases/tag/v4.4.0) (2025-11-29)
|
||||
## What's Changed
|
||||
* chore: bringing in media service refactor previously reverted after more testing by @eddyizm in https://github.com/eddyizm/tempus/pull/286
|
||||
* fix: refactor start queue to put the db writing in the background to address instant mix bug by @eddyizm in https://github.com/eddyizm/tempus/pull/287
|
||||
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue by @eddyizm in https://github.com/eddyizm/tempus/pull/288
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/291
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.3.0...v4.4.0
|
||||
|
||||
## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23)
|
||||
## What's Changed
|
||||
* chore: Add Obtainium badge to README by @mikaeldui in https://github.com/eddyizm/tempus/pull/280
|
||||
* fix: Revert "refactor MediaService" by @eddyizm in https://github.com/eddyizm/tempus/pull/282
|
||||
* feat: add play functionality to library folder/index items by @antebudimir in https://github.com/eddyizm/tempus/pull/276
|
||||
* fix: start queue blocking UI by @eddyizm in https://github.com/eddyizm/tempus/pull/283
|
||||
|
||||
## New Contributors
|
||||
* @mikaeldui made their first contribution in https://github.com/eddyizm/tempus/pull/280
|
||||
* @antebudimir made their first contribution in https://github.com/eddyizm/tempus/pull/276
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.6...v4.3.0
|
||||
|
||||
## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22)
|
||||
## What's Changed
|
||||
* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266
|
||||
* chore: Add Catalan i18n by @marcriera in https://github.com/eddyizm/tempus/pull/268
|
||||
* chore: Refactor MediaService by @pca006132 in https://github.com/eddyizm/tempus/pull/267
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/272
|
||||
* chore(i18n): Update Italian translation by @66Bunz in https://github.com/eddyizm/tempus/pull/278
|
||||
|
||||
## New Contributors
|
||||
* @marcriera made their first contribution in https://github.com/eddyizm/tempus/pull/268
|
||||
* @66Bunz made their first contribution in https://github.com/eddyizm/tempus/pull/278
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.4...v4.2.6
|
||||
|
||||
## [4.2.4](https://github.com/eddyizm/tempo/releases/tag/v4.2.4) (2025-11-15)
|
||||
## What's Changed
|
||||
* chore: Update russian strings.xml by @Sevinfolds in https://github.com/eddyizm/tempus/pull/249
|
||||
* fix: disallow duplicate songs in queue by @eddyizm in https://github.com/eddyizm/tempus/pull/252
|
||||
* fix:github release check by @eddyizm in https://github.com/eddyizm/tempus/pull/253
|
||||
* fix: Fixed crash when viewing share by @drakeerv in https://github.com/eddyizm/tempus/pull/255
|
||||
* chore: Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/257
|
||||
* fix: add podcast/radio channel visible when empty podcasts/radio by @eddyizm in https://github.com/eddyizm/tempus/pull/260
|
||||
|
||||
## New Contributors
|
||||
* @Sevinfolds made their first contribution in https://github.com/eddyizm/tempus/pull/249
|
||||
* @drakeerv made their first contribution in https://github.com/eddyizm/tempus/pull/255
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.0...v4.2.4
|
||||
## [4.2.0](https://github.com/eddyizm/tempo/releases/tag/v4.2.0) (2025-11-09)
|
||||
## What's Changed
|
||||
* fix: Equalizer fix in main build variant by @jaime-grj in https://github.com/eddyizm/tempus/pull/239
|
||||
* fix: Images not filling holder by @eddyizm in https://github.com/eddyizm/tempus/pull/244
|
||||
* feat: Make artist and album clickable by @eddyizm in https://github.com/eddyizm/tempus/pull/243
|
||||
* feat: implement scroll to currently playing feature by @shrapnelnet in https://github.com/eddyizm/tempus/pull/247
|
||||
* fix: shuffling genres only queuing 25 songs by @shrapnelnet in https://github.com/eddyizm/tempus/pull/246
|
||||
|
||||
## New Contributors
|
||||
* @shrapnelnet made their first contribution in https://github.com/eddyizm/tempus/pull/247
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.3...v4.2.0
|
||||
|
||||
## [4.1.3](https://github.com/eddyizm/tempo/releases/tag/v4.1.3) (2025-11-06)
|
||||
## What's Changed
|
||||
* [fix: equalizer missing referenced value](https://github.com/eddyizm/tempus/commit/923cfd5bc97ed7db28c90348e3619d0a784fc434)
|
||||
* Fix: Album track list bug by @eddyizm in https://github.com/eddyizm/tempus/pull/237
|
||||
* fix: Add listener to enable equalizer when audioSessionId changes by @jaime-grj in https://github.com/eddyizm/tempus/pull/235
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.0...v4.1.3
|
||||
|
||||
## [4.1.0](https://github.com/eddyizm/tempo/releases/tag/v4.1.0) (2025-11-05)
|
||||
## What's Changed
|
||||
* chore(i18n): Update Spanish (es-ES) translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/205
|
||||
* shuffle for artists without using `getTopSongs` by @pca006132 in https://github.com/eddyizm/tempus/pull/207
|
||||
* Update USAGE.md with instant mix details by @zc-devs in https://github.com/eddyizm/tempus/pull/220
|
||||
* feat: sort artists by album count by @pca006132 in https://github.com/eddyizm/tempus/pull/206
|
||||
* Fix downloaded tab performance by @pca006132 in https://github.com/eddyizm/tempus/pull/210
|
||||
* fix: remove NestedScrollViews for fragment_album_page by @pca006132 in https://github.com/eddyizm/tempus/pull/216
|
||||
* fix: playlist page should not snap by @pca006132 in https://github.com/eddyizm/tempus/pull/218
|
||||
* fix: do not override getItemViewType and getItemId by @pca006132 in https://github.com/eddyizm/tempus/pull/221
|
||||
* chore: update media3 dependencies by @pca006132 in https://github.com/eddyizm/tempus/pull/217
|
||||
* fix: update MediaItems after network change by @pca006132 in https://github.com/eddyizm/tempus/pull/222
|
||||
* fix: skip mapping downloaded item by @pca006132 in https://github.com/eddyizm/tempus/pull/228
|
||||
|
||||
## New Contributors
|
||||
* @pca006132 made their first contribution in https://github.com/eddyizm/tempus/pull/207
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.7...v4.1.0
|
||||
|
||||
## [4.0.7](https://github.com/eddyizm/tempo/releases/tag/v4.0.7) (2025-10-28)
|
||||
## What's Changed
|
||||
* chore: updated tempo references to tempus including github check by @eddyizm in https://github.com/eddyizm/tempus/pull/197
|
||||
* fix: Crash on share no expiration date or field returned from api by @eddyizm in https://github.com/eddyizm/tempus/pull/199
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.6...v4.0.7
|
||||
|
||||
## [4.0.6](https://github.com/eddyizm/tempo/releases/tag/v4.0.6) (2025-10-26)
|
||||
## Attention
|
||||
This release will not update previous installs as it is considered a new app, no longer `Tempo`, new icon, new app id, and new app name. Hoping it will not be a huge inconvenience but was necessary in order to publish to app stores like IzzyDroid and FDroid.
|
||||
|
||||
**Android Auto**
|
||||
Support should be the same as before, however, I was not able to test any of the icons/visuals, so please let me know if there are any remnants of the tempo logo/icon as I believe I removed them all and replaced them successfully.
|
||||
|
||||
## What's Changed
|
||||
* Check also underlying transport by @zc-devs in https://github.com/eddyizm/tempus/pull/90
|
||||
* fix: updated workflow for 32/64 bit apks by @eddyizm in https://github.com/eddyizm/tempus/pull/176
|
||||
* Unhide genre from album details view by @sebaFlame in https://github.com/eddyizm/tempus/pull/161
|
||||
* fix: persist album sorting on resume by @eddyizm in https://github.com/eddyizm/tempus/pull/181
|
||||
* chore: update readme and usage references to tempus. added new banner… by @eddyizm in https://github.com/eddyizm/tempus/pull/182
|
||||
* Tempus rebrand by @eddyizm in https://github.com/eddyizm/tempus/pull/183
|
||||
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/188
|
||||
|
||||
## New Contributors
|
||||
* @zc-devs made their first contribution in https://github.com/eddyizm/tempus/pull/90
|
||||
* @sebaFlame made their first contribution in https://github.com/eddyizm/tempus/pull/161
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v3.17.14...v4.0.1
|
||||
|
||||
## [3.17.14](https://github.com/eddyizm/tempo/releases/tag/v3.17.14) (2025-10-16)
|
||||
## What's Changed
|
||||
* fix: General build warning and playback issues by @le-firehawk in https://github.com/eddyizm/tempo/pull/167
|
||||
* fix: persist album sort preference by @eddyizm in https://github.com/eddyizm/tempo/pull/168
|
||||
* Fix album parse empty date field by @eddyizm in https://github.com/eddyizm/tempo/pull/171
|
||||
* fix: Include shuffle/repeat controls in f-droid build's media notific… by @le-firehawk in https://github.com/eddyizm/tempo/pull/174
|
||||
* fix: limits image size to prevent widget crash #172 by @eddyizm in https://github.com/eddyizm/tempo/pull/175
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.17.0...v3.17.14
|
||||
|
||||
## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10)
|
||||
## What's Changed
|
||||
* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162
|
||||
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160
|
||||
* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158
|
||||
* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0
|
||||
|
||||
## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08)
|
||||
## What's Changed
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151
|
||||
* fix: Re-add new equalizer settings that got lost by @jaime-grj in https://github.com/eddyizm/tempo/pull/153
|
||||
* chore: removed play variant by @eddyizm in https://github.com/eddyizm/tempo/pull/155
|
||||
* fix: updating release workflow to account for the 32/64 bit builds an… by @eddyizm in https://github.com/eddyizm/tempo/pull/156
|
||||
* feat: Show sampling rate and bit depth in downloads by @jaime-grj in https://github.com/eddyizm/tempo/pull/154
|
||||
* fix: Replace hardcoded strings in SettingsFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/152
|
||||
|
||||
|
||||
([Full Changelog](https://github.com/eddyizm/tempo/compare/v3.10.0...eddyizm:tempo:v3.11.1?expand=1))
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.0...v3.16.6
|
||||
|
||||
## [3.16.0](https://github.com/eddyizm/tempo/releases/tag/v3.16.0) (2025-10-07)
|
||||
## What's Changed
|
||||
* chore: add sha256 fingerprint for validation by @eddyizm in https://github.com/eddyizm/tempo/commit/3c58e6fbb2157a804853259dfadbbffe3b6793b5
|
||||
* fix: Prevent crash when getting artist radio and song list is null by @jaime-grj in https://github.com/eddyizm/tempo/pull/117
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/125
|
||||
* fix: Update search query validation to require at least 2 characters instead of 3 by @jaime-grj in https://github.com/eddyizm/tempo/pull/124
|
||||
* feat: download starred artists. by @eddyizm in https://github.com/eddyizm/tempo/pull/137
|
||||
* feat: Enable downloading of song lyrics for offline viewing by @le-firehawk in https://github.com/eddyizm/tempo/pull/99
|
||||
* fix: Lag during startup when local url is not available by @SinTan1729 in https://github.com/eddyizm/tempo/pull/110
|
||||
* chore: add link to discussion page in settings by @eddyizm in https://github.com/eddyizm/tempo/pull/143
|
||||
* feat: Notification heart rating by @eddyizm in https://github.com/eddyizm/tempo/pull/140
|
||||
* chore: Unify and update polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/146
|
||||
* chore: added sha256 signing key for verification by @eddyizm in https://github.com/eddyizm/tempo/pull/147
|
||||
* feat: Support user-defined download directory for media by @le-firehawk in https://github.com/eddyizm/tempo/pull/21
|
||||
* feat: Added support for skipping duplicates by @SinTan1729 in https://github.com/eddyizm/tempo/pull/135
|
||||
* feat: Add home screen music playback widget and some updates in Turkish localization by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/98
|
||||
|
||||
## New Contributors
|
||||
* @SinTan1729 made their first contribution in https://github.com/eddyizm/tempo/pull/110
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.15.0...v3.16.0
|
||||
|
||||
## [3.15.0](https://github.com/eddyizm/tempo/releases/tag/v3.15.0) (2025-09-23)
|
||||
## What's Changed
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/84
|
||||
* chore: Update RU locale by @ArchiDevil in https://github.com/eddyizm/tempo/pull/87
|
||||
* chore: Update Korean translations by @kongwoojin in https://github.com/eddyizm/tempo/pull/97
|
||||
* fix: only plays the first song on an album by @eddyizm in https://github.com/eddyizm/tempo/pull/81
|
||||
* fix: handle null and not crash when disconnecting chromecast by @eddyizm in https://github.com/eddyizm/tempo/pull/81
|
||||
* feat: Built-in audio equalizer by @jaime-grj in https://github.com/eddyizm/tempo/pull/94
|
||||
* fix: Resolve playback issues with live radio MPEG & HLS streams by @jaime-grj in https://github.com/eddyizm/tempo/pull/89
|
||||
* chore: Updates to polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/105
|
||||
* feat: added 32bit build and debug build for testing. Removed unused f… by @eddyizm in https://github.com/eddyizm/tempo/pull/108
|
||||
* feat: Mark currently playing song with play/pause button by @jaime-grj in https://github.com/eddyizm/tempo/pull/107
|
||||
* fix: add listener to track playlist click/change by @eddyizm in https://github.com/eddyizm/tempo/pull/113
|
||||
* feat: Tap anywhere on the song item to toggle playback by @jaime-grj in https://github.com/eddyizm/tempo/pull/112
|
||||
|
||||
## New Contributors
|
||||
* @ArchiDevil made their first contribution in https://github.com/eddyizm/tempo/pull/87
|
||||
* @kongwoojin made their first contribution in https://github.com/eddyizm/tempo/pull/97
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.8...v3.15.0
|
||||
|
||||
|
||||
## [3.14.8](https://github.com/eddyizm/tempo/releases/tag/v3.14.8) (2025-08-30)
|
||||
## What's Changed
|
||||
* fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/76
|
||||
* chore(i18n): Update Spanish (es-ES) and English translations by @jaime-grj in https://github.com/eddyizm/tempo/pull/77
|
||||
* style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line by @jaime-grj in https://github.com/eddyizm/tempo/pull/78
|
||||
* fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog by @jaime-grj in https://github.com/eddyizm/tempo/pull/79
|
||||
* bug fixes, chores, docs v3.14.8 by @eddyizm in https://github.com/eddyizm/tempo/pull/80
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.1...v3.14.8
|
||||
|
||||
## [3.14.1](https://github.com/eddyizm/tempo/releases/tag/v3.14.1) (2025-08-30)
|
||||
## What's Changed
|
||||
* feat: rating dialog added to album page by @eddyizm in https://github.com/eddyizm/tempo/pull/52
|
||||
* style: Add song rating bar in landscape player controller layout by @jaime-grj in https://github.com/eddyizm/tempo/pull/57
|
||||
* feat: setting to show/hide 5 star rating on playerview by @eddyizm in https://github.com/eddyizm/tempo/pull/59
|
||||
* chore: setting-to-hide-song-rating by @eddyizm in https://github.com/eddyizm/tempo/pull/60
|
||||
* fix: catches null value and prepares bundle appropriately adding sing… by @eddyizm in https://github.com/eddyizm/tempo/pull/64
|
||||
* fix: artist filtering in library view browse artist resolves #45 by @eddyizm in https://github.com/eddyizm/tempo/pull/69
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/70
|
||||
* feat: adds sync starred albums functionality #66 by @eddyizm in https://github.com/eddyizm/tempo/pull/73
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.13.0...v3.14.1
|
||||
|
||||
## [3.13.0](https://github.com/eddyizm/tempo/releases/tag/v3.13.0) (2025-08-23)
|
||||
## What's Changed
|
||||
* style: Change position and size of rating container by @jaime-grj in https://github.com/eddyizm/tempo/pull/44
|
||||
* feat: Add Turkish localization (values-tr) by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/50
|
||||
* chore: adding a note/not fully baked label to the sync user play queue setting by @eddyizm in https://github.com/eddyizm/tempo/commit/8ed0a4642bd0cd637c65e3115142596331fa7ef7
|
||||
* fix: moved hardcoded italian save text to string template, updated with english and italian language xmls by @eddyizm in https://github.com/eddyizm/tempo/commit/26a5fb029a07752c9c0db0d08a89afd638772579
|
||||
|
||||
|
||||
## New Contributors
|
||||
* @mucahit-kaya made their first contribution in https://github.com/eddyizm/tempo/pull/50
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.12.0...v3.13.0
|
||||
|
||||
## [3.12.0](https://github.com/eddyizm/tempo/releases/tag/v3.12.0) (2025-08-15)
|
||||
### What's Changed
|
||||
* [chore]: add German translations for track info and home section strings (#29) by @BreadWare92 in https://github.com/eddyizm/tempo/pull/31
|
||||
* [chore]: increased "Offline mode" text size, changed its color in dark theme by @jaime-grj in https://github.com/eddyizm/tempo/pull/33
|
||||
* [chore]: Translations for sections by @skajmer in https://github.com/eddyizm/tempo/pull/30
|
||||
* [chore]: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/36
|
||||
* [fix]: Show placeholder string in TrackInfoDialog fields when there is no data by @jaime-grj in https://github.com/eddyizm/tempo/pull/37
|
||||
* [feat]: added transcoding codec and bitrate info to PlayerControllerFragment, replace hardcoded strings by @jaime-grj in https://github.com/eddyizm/tempo/pull/38
|
||||
* [chore]: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/39
|
||||
* [feat]: show rating on song view by @eddyizm in https://github.com/eddyizm/tempo/pull/40
|
||||
|
||||
### New Contributors
|
||||
* @BreadWare92 made their first contribution in https://github.com/eddyizm/tempo/pull/31
|
||||
* @skajmer made their first contribution in https://github.com/eddyizm/tempo/pull/30
|
||||
* @benoit-smith made their first contribution in https://github.com/eddyizm/tempo/pull/36
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.11.2...v3.12.0
|
||||
|
||||
## [3.11.2](https://github.com/eddyizm/tempo/releases/tag/v3.11.2) (2025-08-09)
|
||||
|
||||
|
||||
([Full Changelog](https://github.com/eddyizm/tempo/compare/v3.10.0...eddyizm:tempo:v3.11.2?expand=1))
|
||||
|
||||
**Housekeeping:**
|
||||
|
||||
@@ -39,3 +465,5 @@
|
||||
[\#400](https://github.com/CappielloAntonio/tempo/pull/400)
|
||||
- [Chore] Spanish translation [\#374](https://github.com/CappielloAntonio/tempo/pull/374)
|
||||
- [Chore] Polish translation [\#378](https://github.com/CappielloAntonio/tempo/pull/378)
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
141
README.md
141
README.md
@@ -1,62 +1,82 @@
|
||||
<p align="center">
|
||||
<img alt="Tempo" title="Tempo" src="mockup/svg/horizontal_logo.svg" width="250">
|
||||
<img alt="Tempus" title="Tempus" src="mockup/svg/tempus-horizontal-banner.png" width="250">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>Access your music library on all your android devices</b>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/eddyizm/tempus/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/eddyizm/tempus/total.svg?color=4B95DE&style=flat">
|
||||
</a>
|
||||
<!-- Reproducible build -->
|
||||
<a href="https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus"><img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0">
|
||||
<img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/eddyizm/tempo/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
|
||||
<a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a>
|
||||
<a href="https://www.openapk.net/tempus/com.eddyizm.degoogled.tempus/"><img src="https://camo.githubusercontent.com/cd56895b28a73ebd781a65b4f567add5419e45797a5cf1485ce408e851c2318e/68747470733a2f2f7777772e6f70656e61706b2e6e65742f696d616765732f6f70656e61706b2d62616467652e706e67" width="200"></a>
|
||||
</p>
|
||||
<!-- <p align="center">
|
||||
<!--
|
||||
<a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a>
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.cappielloantonio.tempo"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
|
||||
</p> -->
|
||||
-->
|
||||
|
||||
|
||||
**Tempo** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device.
|
||||
**Tempus** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device.
|
||||
|
||||
Tempo does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Last.fm to personalize your music experience.
|
||||
Tempus does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Listenbrainz.org and Last.fm to personalize your music experience (These must be supported by your backend).
|
||||
|
||||
**If you find Tempo useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.**
|
||||
The project is a fork of [Tempo](#credits).
|
||||
|
||||
[Changelog](CHANGELOG.md)
|
||||
[Wiki](USAGE.md)
|
||||
[Donate](https://github.com/eddyizm/tempus#donate)
|
||||
|
||||
**If you find Tempus useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.**
|
||||
|
||||
**Use the Github version of the app for full Android Auto and Chromecast support.**
|
||||
|
||||
## Fork
|
||||
sha256 signing key fingerprint
|
||||
`B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
|
||||
|
||||
This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see.
|
||||
### Releases
|
||||
|
||||
Please note the two variants in the release assets include release/debug and 32/64 bit flavors.
|
||||
|
||||
`app-tempus` <- The github release with all the android auto/chromecast features
|
||||
|
||||
`app-degoogled*` <- The izzyOnDroid release that goes without any of the google stuff. It is now available on izzyOnDroid (64bit) I am releasing the both 32/64bit apk's here on github for those who need a 32bit version.
|
||||
|
||||
Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
|
||||
|
||||
## Features
|
||||
- **Subsonic Integration**: Tempo seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
|
||||
- **Subsonic Integration**: Tempus seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
|
||||
- **Sleek and Intuitive UI**: Enjoy a clean and user-friendly interface designed to enhance your music listening experience, tailored to your preferences and listening history.
|
||||
- **Browse and Search**: Easily navigate through your music library using various browsing and searching options, including artists, albums, genres, playlists, decades and more.
|
||||
- **Streaming and Offline Mode**: Stream music directly from your Subsonic server. Offline mode is currently under active development and may have limitations when using multiple servers.
|
||||
- **Playlist Management**: Create, edit, and manage playlists to curate your perfect music collection.
|
||||
- **Gapless Playback**: Experience uninterrupted playback with gapless listening mode.
|
||||
- **Chromecast Support**: Stream your music to Chromecast devices. The support is currently in a rudimentary state.
|
||||
- **Scrobbling Integration**: Optionally integrate Tempo with Last.fm to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server.
|
||||
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options.
|
||||
- **Chromecast Support**: Stream your music to Chromecast devices. The support is currently in a rudimentary state.*
|
||||
- **Scrobbling Integration**: Optionally integrate Tempus with Last.fm or Listenbrainz.org to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server.
|
||||
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempus, expanding your audio entertainment options.
|
||||
- **Instant Mix**: Full refactor of instant mix function which leverages subsonics similarSongs2 by artist/album and similarSongs endpoints to server a larger play queue more reliably.
|
||||
- **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server.
|
||||
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/feat/1_screenshot.png" width=200>
|
||||
<img src="mockup/feat/2_screenshot.png" width=200>
|
||||
<img src="mockup/feat/3_screenshot.png" width=200>
|
||||
<img src="mockup/feat/4_screenshot.png" width=200>
|
||||
<img src="mockup/feat/5_screenshot.png" width=200>
|
||||
<img src="mockup/feat/6_screenshot.png" width=200>
|
||||
<img src="mockup/feat/7_screenshot.png" width=200>
|
||||
<img src="mockup/feat/8_screenshot.png" width=200>
|
||||
</p>
|
||||
|
||||
## Sponsors
|
||||
Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app.
|
||||
|
||||
If you would like to sponsor the project and show your support, you can make a donation or contribution by visiting the [**sponsorship page**](https://www.buymeacoffee.com/a.cappiello). Your generosity will help cover the costs of development and further enhancements.
|
||||
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.*
|
||||
- **Multiple Libraries**: Tempus handles multi-library setups gracefully. They are displayed as Library folders.
|
||||
- **Equalizer**: Option to use in app equalizer.
|
||||
- **Widget**: New widget to keeping the basic controls on your screen at all times.
|
||||
- **Available in 11 languages**: Currently in Chinese, French, German, Italian, Korean, Polish, Portuguese, Russion, Spanish and Turkish
|
||||
|
||||
**Github version only*
|
||||
|
||||
## Screenshot
|
||||
|
||||
<p align="center">
|
||||
@@ -64,14 +84,13 @@ If you would like to sponsor the project and show your support, you can make a d
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/light/1_screenshot.png" width=200>
|
||||
<img src="mockup/light/2_screenshot.png" width=200>
|
||||
<img src="mockup/light/3_screenshot.png" width=200>
|
||||
<img src="mockup/light/4_screenshot.png" width=200>
|
||||
<img src="mockup/light/5_screenshot.png" width=200>
|
||||
<img src="mockup/light/6_screenshot.png" width=200>
|
||||
<img src="mockup/light/7_screenshot.png" width=200>
|
||||
<img src="mockup/light/8_screenshot.png" width=200>
|
||||
<img src="mockup/1_light_tempus.png" width=200>
|
||||
<img src="mockup/2_light_tempus.png" width=200>
|
||||
<img src="mockup/3_light_tempus.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_light.png" width=200>
|
||||
<!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_light.png" width=200> -->
|
||||
</p>
|
||||
|
||||
<br>
|
||||
@@ -81,16 +100,40 @@ If you would like to sponsor the project and show your support, you can make a d
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/dark/1_screenshot.png" width=200>
|
||||
<img src="mockup/dark/2_screenshot.png" width=200>
|
||||
<img src="mockup/dark/3_screenshot.png" width=200>
|
||||
<img src="mockup/dark/4_screenshot.png" width=200>
|
||||
<img src="mockup/dark/5_screenshot.png" width=200>
|
||||
<img src="mockup/dark/6_screenshot.png" width=200>
|
||||
<img src="mockup/dark/7_screenshot.png" width=200>
|
||||
<img src="mockup/dark/8_screenshot.png" width=200>
|
||||
<img src="mockup/1_dark_tempus.png" width=200>
|
||||
<img src="mockup/2_dark_tempus.png" width=200>
|
||||
<img src="mockup/3_dark_tempus.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_dark.png" width=200>
|
||||
<!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_dark.png" width=200> -->
|
||||
|
||||
</p>
|
||||
|
||||
## Contributing
|
||||
|
||||
Please fork and open PR's against the development branch. Make sure your PR builds successfully.
|
||||
|
||||
If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
|
||||
|
||||
Currently there are no tests but I would love to start on some unit tests.
|
||||
|
||||
Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
|
||||
|
||||
*Special Thanks*
|
||||
All the amazing [contributors](https://github.com/eddyizm/tempus/graphs/contributors)❤️
|
||||
|
||||
## Donate
|
||||
|
||||
[**Buy me a coffee**](https://ko-fi.com/eddyizm)
|
||||
bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5`
|
||||
|
||||
## License
|
||||
|
||||
Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.
|
||||
Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.
|
||||
|
||||
|
||||
## Credits
|
||||
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (forked from v3.9.0)
|
||||
|
||||
[SeattleGuy](https://github.com/SeattleGuy) for the new logo design.
|
||||
|
||||
266
USAGE.md
Normal file
266
USAGE.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# Tempus Usage Guide
|
||||
[<- back home](README.md)
|
||||
|
||||
## Table of Contents
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Server Configuration](#server-configuration)
|
||||
- [Main Features](#main-features)
|
||||
- [Navigation](#navigation)
|
||||
- [Playback Controls](#playback-controls)
|
||||
- [Favorites](#favorites)
|
||||
- [Playlist Management](#playlist-management)
|
||||
- [Android Auto](#android-auto)
|
||||
- [Settings](#settings)
|
||||
- [Known Issues](#known-issues)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Important Notice**: This app is a Subsonic-compatible client and does not provide any music content itself. To use this application, you must have:
|
||||
|
||||
- An active Subsonic API server (or compatible service) already set up
|
||||
- Valid login credentials for your Subsonic server
|
||||
- Music content uploaded and organized on your server
|
||||
|
||||
### Verified backends
|
||||
This app works with any service that implements the Subsonic API, including:
|
||||
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
|
||||
- [Navidrome](https://www.navidrome.org/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [Ampache](https://github.com/ampache/ampache)
|
||||
- [NextCloud Music](https://apps.nextcloud.com/apps/music)
|
||||
- [Airsonic Advanced](https://github.com/kagemomiji/airsonic-advanced)
|
||||
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
1. Download the APK from the [Releases](https://github.com/eddyizm/tempus/releases) section
|
||||
2. Enable "Install from unknown sources" in your Android settings
|
||||
3. Install the application
|
||||
|
||||
### First Launch
|
||||
1. Open the application
|
||||
2. You will be prompted to configure your server connection
|
||||
3. Grant necessary permissions for media playback and background operation
|
||||
|
||||
## Server Configuration
|
||||
|
||||
### Initial Setup
|
||||
**IN PROGRESS**
|
||||
1. Enter your server URL (e.g., `https://your-subsonic-server.com`)
|
||||
2. Provide your username and password
|
||||
3. Test the connection to ensure proper configuration
|
||||
|
||||
### Advanced Settings
|
||||
**TODO**
|
||||
|
||||
## Main Features
|
||||
|
||||
### Library View
|
||||
|
||||
**Multi-library**
|
||||
|
||||
Tempus handles multi-library setups gracefully. They are displayed as Library folders.
|
||||
|
||||
However, if you want to limit or change libraries you could use a workaround, if your server supports it.
|
||||
|
||||
You can create multiple users , one for each library, and save each of them in Tempus app.
|
||||
|
||||
### Folder or index playback
|
||||
|
||||
If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy:
|
||||
|
||||
<p align="left">
|
||||
<img src="mockup/usage/music_folders_root.png" width=317 style="margin-right:16px;">
|
||||
<img src="mockup/usage/music_folders_playback.png" width=317>
|
||||
</p>
|
||||
|
||||
- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator.
|
||||
- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders.
|
||||
- Video files are excluded automatically, so only playable audio ends up in the queue.
|
||||
|
||||
No extra config is needed—Tempus adjusts based on the connected backend.
|
||||
|
||||
### Now Playing Screen
|
||||
|
||||
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
|
||||
<p align="left">
|
||||
<img src="mockup/usage/player_icons.png" width=159>
|
||||
</p>
|
||||
|
||||
*marked the icons with numbers for clarity*
|
||||
|
||||
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
|
||||
2. Adds track to playlist - pops up playlist dialog.
|
||||
3. Adds tracks to the queue via instant mix function
|
||||
* TBD: what is the _instant mix function_?
|
||||
* Uses [getSimilarSongs](https://opensubsonic.netlify.app/docs/endpoints/getsimilarsongs/) of OpenSubsonic API.
|
||||
Which tracks to be mixed depends on the server implementation. For example, Navidrome gets 15 similar artists from LastFM, then 20 top songs from each.
|
||||
4. Saves play queue (if the feature is enabled in the settings)
|
||||
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
|
||||
|
||||
### Podcasts
|
||||
If your server supports it - add a podcast rss feed
|
||||
<p align="left">
|
||||
<img src="mockup/usage/add_podcast_feed.png" width=317>
|
||||
</p>
|
||||
|
||||
### Radio Stations
|
||||
If your server supports it - add a internet radio station feed
|
||||
<p align="left">
|
||||
<img src="mockup/usage/add_radio_station.png" width=326>
|
||||
</p>
|
||||
|
||||
## Navigation
|
||||
|
||||
### Bottom Navigation Bar
|
||||
**IN PROGRESS**
|
||||
- **Home**: Recently played and server recommendations
|
||||
- **Library**: Your server's complete music collection
|
||||
- **Download**: Locally downloaded files from server
|
||||
|
||||
## Playback Controls
|
||||
|
||||
### Streaming Controls
|
||||
**TODO**
|
||||
|
||||
### Advanced Controls
|
||||
**TODO**
|
||||
|
||||
## Favorites
|
||||
|
||||
### Favorites (aka heart aka star) to albums and artists
|
||||
- Long pressing on an album gives you access to heart/unheart an album
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/usage/fave_album.png" width=376>
|
||||
</p>
|
||||
|
||||
- Long pressing on an artist cover gets you the same access to to heart/unheart an album
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/usage/fave_artist.png" width=376>
|
||||
</p>
|
||||
|
||||
|
||||
## Playlist Management
|
||||
|
||||
### Server Playlists
|
||||
**TODO**
|
||||
|
||||
### Creating Playlists
|
||||
**TODO**
|
||||
|
||||
## Settings
|
||||
|
||||
|
||||
## Android Auto
|
||||
|
||||
**Enabling on your head unit**
|
||||
|
||||
To allow the Tempus app on your car's head unit, "Unknown sources" needs to be enabled in the Android Auto "Developer settings". This is because Tempus isn't installed through Play Store. Note that the Android Auto developer settings are different from the global Android "Developer options".
|
||||
1. Switch to developer mode in the Android Auto settings by tapping ten times on the "Version" item at the bottom, followed by giving your permission.
|
||||
<p align="left">
|
||||
<img width="270" height="600" alt="1a" src="https://github.com/user-attachments/assets/f09f6999-9761-4b05-8ec7-bf221a15dda3" />
|
||||
<img width="270" height="600" alt="1b" src="https://github.com/user-attachments/assets/0795e508-ba01-41c5-96a7-7c03b0156591" />
|
||||
<img width="270" height="600" alt="1c" src="https://github.com/user-attachments/assets/51c15f67-fddb-452e-b5d3-5092edeab390" />
|
||||
</p>
|
||||
|
||||
2. Go to the "Developer settings" by the menu at the top right.
|
||||
<p align="left">
|
||||
<img width="270" height="600" alt="2" src="https://github.com/user-attachments/assets/1ecd1f3e-026d-4d25-87f2-be7f12efbac6" />
|
||||
</p>
|
||||
|
||||
3. Scroll down to the bottom and check "Unknown sources".
|
||||
<p align="left">
|
||||
<img width="270" height="600" alt="3" src="https://github.com/user-attachments/assets/37db88e9-1b76-417f-9c47-da9f3a750fff" />
|
||||
</p>
|
||||
|
||||
**Interface Configuration**
|
||||
|
||||
The Android Auto interface can be configured by user to best suit their preferences.
|
||||
|
||||
<p align="left">
|
||||
<img src="mockup/usage/aa_preferences.png" width=317 style="margin-right:16px;">
|
||||
<img src="mockup/usage/aa_functions.png" width=317>
|
||||
</p>
|
||||
|
||||
4 tabs can be configured with the following functions:
|
||||
- Do not display : This tab is not used
|
||||
- Home : Displays all functions not used in other tabs
|
||||
- Recent : The 15 most recently listened-to albums
|
||||
- Albums : Albums sorted by name
|
||||
- Artists : Albums sorted by artist
|
||||
- Playlists
|
||||
- Podcast : The 100 podcasts recently added
|
||||
- Radio
|
||||
- Folder : Navigation through music directories
|
||||
- Albums most played : The 15 most played albums
|
||||
- Albums added : The 15 recently added albums
|
||||
- Star tracks
|
||||
- Star albums
|
||||
- Star artists
|
||||
- Random : 100 random songs
|
||||
- Genres : 500 songs of the chosen genre OR 100 random songs if "shuffle genre songs" is selected
|
||||
|
||||
If all tabs are set to "Do not display", then "Home" tab will be created with all functions inside.
|
||||
|
||||
If "Home" is selected after another tab, it becomes "More"
|
||||
|
||||
In addition, you can choose to display the following functions as thumbnails or lists:
|
||||
- Home
|
||||
- Albums (Last played, Most played, Recently added, Artists, Star tracks, Star albums, Star artists, Random)
|
||||
- Playlists
|
||||
- Radio
|
||||
- Podcast
|
||||
|
||||
<p align="left">
|
||||
<img src="mockup/usage/aa_thumbnails.jpg" width=317 style="margin-right:16px;">
|
||||
<img src="mockup/usage/aa_list.jpg" width=317>
|
||||
</p>
|
||||
|
||||
The A-Z button allows you to jump to items starting with the chosen letter.
|
||||
|
||||
Search button returns albums or artists, even if they are not displayed by the selected function.
|
||||
|
||||
Results of the A-Z jump or search will always be displayed as a list.
|
||||
|
||||
<p align="left">
|
||||
<img src="mockup/usage/aa_AZ.jpg" width=317 style="margin-right:16px;">
|
||||
<img src="mockup/usage/aa_search.jpg" width=317>
|
||||
</p>
|
||||
|
||||
Display of albums and artists is limited to 500. For large libraries, it's preferable to use star albums or star artists.
|
||||
|
||||
### Server Settings
|
||||
**IN PROGRESS**
|
||||
- Manage multiple server connections
|
||||
- Configure sync intervals
|
||||
- Set data usage limits for streaming
|
||||
|
||||
### Audio Settings
|
||||
**IN PROGRESS**
|
||||
- Streaming quality settings
|
||||
- Offline caching preferences
|
||||
|
||||
### Appearance
|
||||
**TODO**
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Airsonic Distorted Playback
|
||||
|
||||
First reported in issue [#226](https://github.com/eddyizm/tempus/issues/226)
|
||||
The work around is to disable the cache in the settings, (set to 0), and if needed, cleaning the (Android) cache fixes the problem.
|
||||
|
||||
### Support
|
||||
For additional help:
|
||||
- Question? Start a [Discussion](https://github.com/eddyizm/tempus/discussions)
|
||||
- Open an [issue](https://github.com/eddyizm/tempus/issues) if you don't find a discussion solving your issue.
|
||||
- Consult your Subsonic server's documentation
|
||||
|
||||
---
|
||||
|
||||
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*
|
||||
@@ -10,9 +10,8 @@ android {
|
||||
minSdkVersion 24
|
||||
targetSdk 35
|
||||
|
||||
versionCode 27
|
||||
versionName '3.11.0'
|
||||
|
||||
versionCode 24
|
||||
versionName '4.13.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
javaCompileOptions {
|
||||
@@ -23,25 +22,39 @@ android {
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
//noinspection ChromeOsAbiSupport
|
||||
include 'armeabi-v7a', 'arm64-v8a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles (for Google Play)
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
flavorDimensions += "default"
|
||||
|
||||
productFlavors {
|
||||
tempo {
|
||||
tempus {
|
||||
dimension = "default"
|
||||
applicationId 'com.cappielloantonio.tempo'
|
||||
applicationId 'com.eddyizm.tempus'
|
||||
}
|
||||
|
||||
notquitemy {
|
||||
degoogled {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.notquitemy.tempo"
|
||||
applicationId "com.eddyizm.degoogled.tempus"
|
||||
}
|
||||
|
||||
play {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.play.tempo"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -51,6 +64,11 @@ android {
|
||||
debuggable false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -83,6 +101,7 @@ dependencies {
|
||||
implementation 'androidx.room:room-runtime:2.6.1'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0"
|
||||
|
||||
// Android Material
|
||||
implementation 'com.google.android.material:material:1.10.0'
|
||||
@@ -92,13 +111,13 @@ dependencies {
|
||||
implementation 'com.github.bumptech.glide:annotations:4.16.0'
|
||||
|
||||
// Media3
|
||||
implementation 'androidx.media3:media3-session:1.5.1'
|
||||
implementation 'androidx.media3:media3-common:1.5.1'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.5.1'
|
||||
implementation 'androidx.media3:media3-ui:1.5.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.5.1'
|
||||
tempoImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
playImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
implementation 'androidx.media3:media3-session:1.8.0'
|
||||
implementation 'androidx.media3:media3-common:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
||||
tempusImplementation 'androidx.media3:media3-cast:1.8.0'
|
||||
|
||||
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
annotationProcessor 'androidx.room:room-compiler:2.6.1'
|
||||
@@ -112,4 +131,4 @@ java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
File diff suppressed because it is too large
Load Diff
1158
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/13.json
Normal file
1158
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/13.json
Normal file
File diff suppressed because it is too large
Load Diff
1164
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json
Normal file
1164
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
@UnstableApi
|
||||
class MediaService : BaseMediaService()
|
||||
10
app/src/degoogled/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/degoogled/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FF36C12C"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
78
app/src/degoogled/res/drawable/ic_launcher_foreground.xml
Normal file
78
app/src/degoogled/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:scaleX="0.13"
|
||||
android:scaleY="0.13"
|
||||
android:translateX="21.5"
|
||||
android:translateY="21.5">
|
||||
|
||||
<path
|
||||
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="122.34"
|
||||
android:startY="23.55"
|
||||
android:endX="377.69"
|
||||
android:endY="465.83"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#FF36C12C" />
|
||||
<item android:offset="1.0" android:color="#FF36C12C" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="116.21"
|
||||
android:startY="67.61"
|
||||
android:endX="403.29"
|
||||
android:endY="429.34"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#66060606" />
|
||||
<item android:offset="1.0" android:color="#CC060606" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:fillColor="#66FFFFFF"
|
||||
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
|
||||
</group>
|
||||
</vector>
|
||||
77
app/src/degoogled/res/drawable/ic_splash_logo.xml
Normal file
77
app/src/degoogled/res/drawable/ic_splash_logo.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:scaleX="0.13"
|
||||
android:scaleY="0.13"
|
||||
android:translateX="21.5"
|
||||
android:translateY="21.5">
|
||||
<path
|
||||
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="122.34"
|
||||
android:startY="23.55"
|
||||
android:endX="377.69"
|
||||
android:endY="465.83"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#FF36C12C" />
|
||||
<item android:offset="1.0" android:color="#FF36C12C" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="116.21"
|
||||
android:startY="67.61"
|
||||
android:endX="403.29"
|
||||
android:endY="429.34"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#66060606" />
|
||||
<item android:offset="1.0" android:color="#CC060606" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:fillColor="#66FFFFFF"
|
||||
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
|
||||
</group>
|
||||
</vector>
|
||||
77
app/src/degoogled/res/drawable/ic_toolbar_tempo.xml
Normal file
77
app/src/degoogled/res/drawable/ic_toolbar_tempo.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="522">
|
||||
<group
|
||||
android:scaleX="1.0"
|
||||
android:scaleY="1.0"
|
||||
android:translateX="14.0"
|
||||
android:translateY="14.0">
|
||||
|
||||
<path
|
||||
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="122.34"
|
||||
android:startY="23.55"
|
||||
android:endX="377.69"
|
||||
android:endY="465.83"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#FF36C12C" />
|
||||
<item android:offset="1.0" android:color="#FF36C12C" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="116.21"
|
||||
android:startY="67.61"
|
||||
android:endX="403.29"
|
||||
android:endY="429.34"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#66060606" />
|
||||
<item android:offset="1.0" android:color="#CC060606" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:fillColor="#66FFFFFF"
|
||||
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
|
||||
</group>
|
||||
</vector>
|
||||
78
app/src/degoogled/res/drawable/logo.xml
Normal file
78
app/src/degoogled/res/drawable/logo.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:scaleX="0.16"
|
||||
android:scaleY="0.16"
|
||||
android:translateX="14.0"
|
||||
android:translateY="14.0">
|
||||
|
||||
<path
|
||||
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="122.34"
|
||||
android:startY="23.55"
|
||||
android:endX="377.69"
|
||||
android:endY="465.83"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#FF36C12C" />
|
||||
<item android:offset="1.0" android:color="#FF36C12C" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="116.21"
|
||||
android:startY="67.61"
|
||||
android:endX="403.29"
|
||||
android:endY="429.34"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#66060606" />
|
||||
<item android:offset="1.0" android:color="#CC060606" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="420.63"
|
||||
android:startY="403.74"
|
||||
android:endX="78.4"
|
||||
android:endY="117.92"
|
||||
android:type="linear">
|
||||
<item android:offset="0.0" android:color="#33FFFFFF" />
|
||||
<item android:offset="1.0" android:color="#4DFFFFFF" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<path
|
||||
android:fillColor="#66FFFFFF"
|
||||
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
|
||||
</group>
|
||||
</vector>
|
||||
6
app/src/degoogled/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
6
app/src/degoogled/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -42,6 +42,16 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="asset"
|
||||
android:scheme="tempo" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
@@ -73,5 +83,25 @@
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.WidgetProvider4x1"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info"/>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".provider.AlbumArtContentProvider"
|
||||
android:authorities="${applicationId}.albumart.provider"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
/>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.cappielloantonio.tempo.github.Github;
|
||||
import com.cappielloantonio.tempo.helper.ThemeHelper;
|
||||
import com.cappielloantonio.tempo.subsonic.Subsonic;
|
||||
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
|
||||
import com.cappielloantonio.tempo.util.ClientCertManager;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
public class App extends Application {
|
||||
@@ -31,6 +32,8 @@ public class App extends Application {
|
||||
instance = new App();
|
||||
context = getApplicationContext();
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
||||
ClientCertManager.setupSslSocketFactory(context);
|
||||
}
|
||||
|
||||
public static App getInstance() {
|
||||
@@ -55,6 +58,48 @@ public class App extends Application {
|
||||
}
|
||||
return subsonic;
|
||||
}
|
||||
|
||||
public static Subsonic getSubsonicPublicClientInstance(boolean override) {
|
||||
|
||||
/*
|
||||
If I do the shortcut that the IDE suggests:
|
||||
SubsonicPreferences preferences = getSubsonicPreferences1();
|
||||
During the chain of calls it will run the following:
|
||||
String server = Preferences.getInUseServerAddress();
|
||||
Which could return Local URL, causing issues like generating public shares with Local URL
|
||||
|
||||
To prevent this I just replicated the entire chain of functions here,
|
||||
if you need a call to Subsonic using the Server (Public) URL use this function.
|
||||
*/
|
||||
|
||||
String server = Preferences.getServer();
|
||||
String username = Preferences.getUser();
|
||||
String password = Preferences.getPassword();
|
||||
String token = Preferences.getToken();
|
||||
String salt = Preferences.getSalt();
|
||||
boolean isLowSecurity = Preferences.isLowScurity();
|
||||
|
||||
SubsonicPreferences preferences = new SubsonicPreferences();
|
||||
preferences.setServerUrl(server);
|
||||
preferences.setUsername(username);
|
||||
preferences.setAuthentication(password, token, salt, isLowSecurity);
|
||||
|
||||
if (subsonic == null || override) {
|
||||
|
||||
if (preferences.getAuthentication() != null) {
|
||||
if (preferences.getAuthentication().getPassword() != null)
|
||||
Preferences.setPassword(preferences.getAuthentication().getPassword());
|
||||
if (preferences.getAuthentication().getToken() != null)
|
||||
Preferences.setToken(preferences.getAuthentication().getToken());
|
||||
if (preferences.getAuthentication().getSalt() != null)
|
||||
Preferences.setSalt(preferences.getAuthentication().getSalt());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
return new Subsonic(preferences);
|
||||
}
|
||||
|
||||
public static Github getGithubClientInstance() {
|
||||
if (github == null) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters;
|
||||
import com.cappielloantonio.tempo.database.dao.ChronologyDao;
|
||||
import com.cappielloantonio.tempo.database.dao.DownloadDao;
|
||||
import com.cappielloantonio.tempo.database.dao.FavoriteDao;
|
||||
import com.cappielloantonio.tempo.database.dao.LyricsDao;
|
||||
import com.cappielloantonio.tempo.database.dao.PlaylistDao;
|
||||
import com.cappielloantonio.tempo.database.dao.QueueDao;
|
||||
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
|
||||
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao;
|
||||
import com.cappielloantonio.tempo.model.Chronology;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.model.Favorite;
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
import com.cappielloantonio.tempo.model.Queue;
|
||||
import com.cappielloantonio.tempo.model.RecentSearch;
|
||||
import com.cappielloantonio.tempo.model.Server;
|
||||
@@ -28,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
|
||||
@UnstableApi
|
||||
@Database(
|
||||
version = 11,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class},
|
||||
autoMigrations = {@AutoMigration(from = 10, to = 11)}
|
||||
version = 14,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
|
||||
autoMigrations = {
|
||||
@AutoMigration(from = 10, to = 11),
|
||||
@AutoMigration(from = 11, to = 12),
|
||||
@AutoMigration(from = 13, to = 14),
|
||||
}
|
||||
)
|
||||
@TypeConverters({DateConverters.class})
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
@@ -62,4 +68,6 @@ public abstract class AppDatabase extends RoomDatabase {
|
||||
public abstract SessionMediaItemDao sessionMediaItemDao();
|
||||
|
||||
public abstract PlaylistDao playlistDao();
|
||||
|
||||
public abstract LyricsDao lyricsDao();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ public interface DownloadDao {
|
||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
LiveData<List<Download>> getAll();
|
||||
|
||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
List<Download> getAllSync();
|
||||
|
||||
@Query("SELECT * FROM download WHERE id = :id")
|
||||
Download getOne(String id);
|
||||
|
||||
@@ -30,6 +33,9 @@ public interface DownloadDao {
|
||||
@Query("DELETE FROM download WHERE id = :id")
|
||||
void delete(String id);
|
||||
|
||||
@Query("DELETE FROM download WHERE id IN (:ids)")
|
||||
void deleteByIds(List<String> ids);
|
||||
|
||||
@Query("DELETE FROM download")
|
||||
void deleteAll();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cappielloantonio.tempo.database.dao;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Insert;
|
||||
import androidx.room.OnConflictStrategy;
|
||||
import androidx.room.Query;
|
||||
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
|
||||
@Dao
|
||||
public interface LyricsDao {
|
||||
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
|
||||
LyricsCache getOne(String songId);
|
||||
|
||||
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
|
||||
LiveData<LyricsCache> observeOne(String songId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(LyricsCache lyricsCache);
|
||||
|
||||
@Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
|
||||
void delete(String songId);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public interface PlaylistDao {
|
||||
@Query("SELECT * FROM playlist")
|
||||
LiveData<List<Playlist>> getAll();
|
||||
|
||||
@Query("SELECT * FROM playlist")
|
||||
List<Playlist> getAllSync();
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(Playlist playlist);
|
||||
|
||||
|
||||
@@ -12,9 +12,12 @@ import java.util.List;
|
||||
|
||||
@Dao
|
||||
public interface RecentSearchDao {
|
||||
@Query("SELECT * FROM recent_search ORDER BY search DESC")
|
||||
@Query("SELECT search FROM recent_search ORDER BY timestamp DESC")
|
||||
List<String> getRecent();
|
||||
|
||||
@Query("SELECT search FROM recent_search ORDER BY search DESC")
|
||||
List<String> getAlpha();
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(RecentSearch search);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.cappielloantonio.tempo.github;
|
||||
import com.cappielloantonio.tempo.github.api.release.ReleaseClient;
|
||||
|
||||
public class Github {
|
||||
private static final String OWNER = "CappielloAntonio";
|
||||
private static final String REPO = "Tempo";
|
||||
private static final String OWNER = "eddyizm";
|
||||
private static final String REPO = "Tempus";
|
||||
private ReleaseClient releaseClient;
|
||||
|
||||
public ReleaseClient getReleaseClient() {
|
||||
|
||||
@@ -7,10 +7,11 @@ public class UpdateUtil {
|
||||
|
||||
public static boolean showUpdateDialog(LatestRelease release) {
|
||||
if (release.getTagName() == null) return false;
|
||||
String remoteTag = release.getTagName().replaceAll("^\\D+", "");
|
||||
|
||||
try {
|
||||
String[] local = BuildConfig.VERSION_NAME.split("\\.");
|
||||
String[] remote = release.getTagName().split("\\.");
|
||||
String[] remote = remoteTag.split("\\.");
|
||||
|
||||
for (int i = 0; i < local.length; i++) {
|
||||
int localPart = Integer.parseInt(local[i]);
|
||||
|
||||
@@ -4,14 +4,18 @@ import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.GlideBuilder;
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
|
||||
import com.bumptech.glide.Registry;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@GlideModule
|
||||
public class CustomGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
@@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule {
|
||||
builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize));
|
||||
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
|
||||
registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cappielloantonio.tempo.glide;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.Log;
|
||||
@@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
@@ -109,9 +111,21 @@ public class CustomGlideRequest {
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
public static void loadAlbumArtBitmap(Context context,
|
||||
String coverId,
|
||||
int size,
|
||||
CustomTarget<Bitmap> target) {
|
||||
String url = createUrl(coverId, size);
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(url)
|
||||
.apply(createRequestOptions(context, coverId, ResourceType.Album))
|
||||
.into(target);
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private final RequestManager requestManager;
|
||||
private Object item;
|
||||
private String item;
|
||||
|
||||
private Builder(Context context, String item, ResourceType type) {
|
||||
this.requestManager = Glide.with(context);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.cappielloantonio.tempo.glide;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.Options;
|
||||
import com.bumptech.glide.load.data.DataFetcher;
|
||||
import com.bumptech.glide.load.model.ModelLoader;
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
|
||||
private static final int DEFAULT_TIMEOUT_MS = 2500;
|
||||
|
||||
@Override
|
||||
public boolean handles(@NonNull String model) {
|
||||
return model.startsWith("http://") || model.startsWith("https://");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
|
||||
if (!handles(model)) {
|
||||
return null;
|
||||
}
|
||||
return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model));
|
||||
}
|
||||
|
||||
private static class IPv6StreamFetcher implements DataFetcher<InputStream> {
|
||||
private final String model;
|
||||
private InputStream stream;
|
||||
private HttpURLConnection connection;
|
||||
|
||||
IPv6StreamFetcher(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
|
||||
try {
|
||||
URL url = new URL(model);
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setUseCaches(true);
|
||||
connection.setDoInput(true);
|
||||
connection.connect();
|
||||
|
||||
if (connection.getResponseCode() / 100 != 2) {
|
||||
callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode()));
|
||||
return;
|
||||
}
|
||||
|
||||
stream = connection.getInputStream();
|
||||
callback.onDataReady(stream);
|
||||
} catch (IOException e) {
|
||||
callback.onLoadFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
if (stream != null) {
|
||||
try {
|
||||
stream.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
// HttpURLConnection does not provide a direct cancel mechanism.
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Class<InputStream> getDataClass() {
|
||||
return InputStream.class;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public DataSource getDataSource() {
|
||||
return DataSource.REMOTE;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Factory implements ModelLoaderFactory<String, InputStream> {
|
||||
@NonNull
|
||||
@Override
|
||||
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
|
||||
return new IPv6StringLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teardown() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,11 @@ public interface ClickCallback {
|
||||
default void onInternetRadioStationClick(Bundle bundle) {}
|
||||
default void onInternetRadioStationLongClick(Bundle bundle) {}
|
||||
default void onMusicFolderClick(Bundle bundle) {}
|
||||
default void onMusicFolderPlay(Bundle bundle) {}
|
||||
default void onMusicDirectoryClick(Bundle bundle) {}
|
||||
default void onMusicDirectoryPlay(Bundle bundle) {}
|
||||
default void onMusicIndexClick(Bundle bundle) {}
|
||||
default void onMusicIndexPlay(Bundle bundle) {}
|
||||
default void onDownloadGroupLongClick(Bundle bundle) {}
|
||||
default void onShareClick(Bundle bundle) {}
|
||||
default void onShareLongClick(Bundle bundle) {}
|
||||
|
||||
@@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "chronology")
|
||||
class Chronology(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Chronology(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "timestamp")
|
||||
var timestamp: Long = System.currentTimeMillis()
|
||||
|
||||
var timestamp: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "server")
|
||||
var server: String? = null
|
||||
|
||||
var server: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
|
||||
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
|
||||
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")
|
||||
|
||||
@@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "download")
|
||||
class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Download(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "playlist_id")
|
||||
var playlistId: String? = null
|
||||
|
||||
var playlistId: String? = null,
|
||||
@ColumnInfo(name = "playlist_name")
|
||||
var playlistName: String? = null
|
||||
|
||||
var playlistName: String? = null,
|
||||
@ColumnInfo(name = "download_state", defaultValue = "1")
|
||||
var downloadState: Int = 0
|
||||
|
||||
var downloadState: Int = 0,
|
||||
@ColumnInfo(name = "download_uri", defaultValue = "")
|
||||
var downloadUri: String? = null
|
||||
|
||||
var downloadUri: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(child: Child) : this(child.id) {
|
||||
parentId = child.parentId
|
||||
isDir = child.isDir
|
||||
@@ -40,6 +38,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
transcodedSuffix = child.transcodedSuffix
|
||||
duration = child.duration
|
||||
bitrate = child.bitrate
|
||||
samplingRate = child.samplingRate
|
||||
bitDepth = child.bitDepth
|
||||
path = child.path
|
||||
isVideo = child.isVideo
|
||||
userRating = child.userRating
|
||||
@@ -60,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
@Keep
|
||||
data class DownloadStack(
|
||||
var id: String,
|
||||
var view: String?
|
||||
var view: String?,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.cappielloantonio.tempo.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlin.jvm.JvmOverloads
|
||||
|
||||
@Keep
|
||||
@Entity(tableName = "lyrics_cache")
|
||||
data class LyricsCache @JvmOverloads constructor(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "song_id")
|
||||
var songId: String,
|
||||
@ColumnInfo(name = "artist")
|
||||
var artist: String? = null,
|
||||
@ColumnInfo(name = "title")
|
||||
var title: String? = null,
|
||||
@ColumnInfo(name = "lyrics")
|
||||
var lyrics: String? = null,
|
||||
@ColumnInfo(name = "structured_lyrics")
|
||||
var structuredLyrics: String? = null,
|
||||
@ColumnInfo(name = "updated_at")
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "queue")
|
||||
class Queue(override val id: String) : Child(id) {
|
||||
class Queue(
|
||||
override val id: String,
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "track_order")
|
||||
var trackOrder: Int = 0
|
||||
|
||||
var trackOrder: Int = 0,
|
||||
@ColumnInfo(name = "last_play")
|
||||
var lastPlay: Long = 0
|
||||
|
||||
var lastPlay: Long = 0,
|
||||
@ColumnInfo(name = "playing_changed")
|
||||
var playingChanged: Long = 0
|
||||
|
||||
var playingChanged: Long = 0,
|
||||
@ColumnInfo(name = "stream_id")
|
||||
var streamId: String? = null
|
||||
|
||||
var streamId: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(child: Child) : this(child.id) {
|
||||
parentId = child.parentId
|
||||
isDir = child.isDir
|
||||
|
||||
@@ -13,5 +13,8 @@ import kotlinx.parcelize.Parcelize
|
||||
data class RecentSearch(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "search")
|
||||
var search: String
|
||||
var search: String,
|
||||
|
||||
@ColumnInfo(name = "timestamp", defaultValue = "0")
|
||||
var timestamp: Long
|
||||
) : Parcelable
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
@@ -35,5 +34,8 @@ data class Server(
|
||||
val timestamp: Long,
|
||||
|
||||
@ColumnInfo(name = "low_security", defaultValue = "false")
|
||||
val isLowSecurity: Boolean
|
||||
val isLowSecurity: Boolean,
|
||||
|
||||
@ColumnInfo(name = "client_cert")
|
||||
val clientCert: String?,
|
||||
) : Parcelable
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.cappielloantonio.tempo.model
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.Keep
|
||||
import androidx.media3.common.HeartRating
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.MediaMetadata
|
||||
@@ -12,6 +14,7 @@ import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest
|
||||
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child
|
||||
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode
|
||||
@@ -192,11 +195,20 @@ class SessionMediaItem() {
|
||||
title = internetRadioStation.name
|
||||
streamUrl = internetRadioStation.streamUrl
|
||||
type = Constants.MEDIA_TYPE_RADIO
|
||||
|
||||
val homePageUrl = internetRadioStation.homePageUrl
|
||||
if (homePageUrl != null && homePageUrl.isNotEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
|
||||
val encodedUrl = android.util.Base64.encodeToString(
|
||||
homePageUrl.toByteArray(java.nio.charset.StandardCharsets.UTF_8),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||
)
|
||||
coverArtId = "ir_$encodedUrl"
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaItem(): MediaItem {
|
||||
val uri: Uri = getStreamUri()
|
||||
val artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, getImageSize()))
|
||||
val artworkUri = if (coverArtId != null) AlbumArtContentProvider.contentUri(coverArtId!!) else null
|
||||
|
||||
val bundle = Bundle()
|
||||
bundle.putString("id", id)
|
||||
@@ -226,7 +238,7 @@ class SessionMediaItem() {
|
||||
bundle.putLong("starred", starred?.time ?: 0)
|
||||
bundle.putString("albumId", albumId)
|
||||
bundle.putString("artistId", artistId)
|
||||
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC)
|
||||
bundle.putString("type", type)
|
||||
bundle.putLong("bookmarkPosition", bookmarkPosition ?: 0)
|
||||
bundle.putInt("originalWidth", originalWidth ?: 0)
|
||||
bundle.putInt("originalHeight", originalHeight ?: 0)
|
||||
@@ -243,6 +255,13 @@ class SessionMediaItem() {
|
||||
.setAlbumTitle(album)
|
||||
.setArtist(artist)
|
||||
.setArtworkUri(artworkUri)
|
||||
.setUserRating(HeartRating(starred != null))
|
||||
.setSupportedCommands(
|
||||
listOf(
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
|
||||
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
|
||||
)
|
||||
)
|
||||
.setExtras(bundle)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.cappielloantonio.tempo.navigation;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.navigation.NavController;
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
|
||||
public class NavigationController {
|
||||
|
||||
NavigationHelper helper;
|
||||
|
||||
public NavigationController(@NonNull NavigationHelper helper) {
|
||||
this.helper = helper;
|
||||
}
|
||||
|
||||
public void syncWithBottomSheetBehavior(BottomSheetBehavior<View> bottomSheetBehavior,
|
||||
NavController navController) {
|
||||
helper.syncWithBottomSheetBehavior(bottomSheetBehavior, navController);
|
||||
|
||||
}
|
||||
|
||||
public void setNavbarVisibility(boolean visibility) {
|
||||
helper.setBottomNavigationBarVisibility(visibility);
|
||||
}
|
||||
|
||||
public void setDrawerLock(boolean visibility) {
|
||||
helper.setNavigationDrawerLock(visibility);
|
||||
}
|
||||
|
||||
public boolean isNavigationDrawerLocked() {
|
||||
return helper.isNavigationDrawerLocked();
|
||||
}
|
||||
|
||||
public void toggleDrawerLockOnOrientation(AppCompatActivity activity) {
|
||||
helper.toggleNavigationDrawerLockOnOrientationChange(activity);
|
||||
}
|
||||
|
||||
public void setSystemBarsVisibility(AppCompatActivity activity, boolean visibility) {
|
||||
helper.setSystemBarsVisibility(activity, visibility);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.cappielloantonio.tempo.navigation;
|
||||
|
||||
import android.content.res.Configuration;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.NavDestination;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.navigation.ui.NavigationUI;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
import com.google.android.material.navigation.NavigationView;
|
||||
|
||||
import org.jetbrains.annotations.Contract;
|
||||
|
||||
public class NavigationHelper {
|
||||
/* UI components */
|
||||
private BottomNavigationView bottomNavigationView;
|
||||
private FrameLayout bottomNavigationViewFrame;
|
||||
private DrawerLayout drawerLayout;
|
||||
|
||||
/* Navigation components */
|
||||
private NavigationView navigationView;
|
||||
private NavHostFragment navHostFragment;
|
||||
|
||||
/* States that need to be remembered */
|
||||
// -- //
|
||||
|
||||
/* Private constructor */
|
||||
public NavigationHelper(@NonNull BottomNavigationView bottomNavigationView,
|
||||
@NonNull FrameLayout bottomNavigationViewFrame,
|
||||
@NonNull DrawerLayout drawerLayout,
|
||||
@NonNull NavigationView navigationView,
|
||||
@NonNull NavHostFragment navHostFragment) {
|
||||
this.bottomNavigationView = bottomNavigationView;
|
||||
this.bottomNavigationViewFrame = bottomNavigationViewFrame;
|
||||
this.drawerLayout = drawerLayout;
|
||||
this.navigationView = navigationView;
|
||||
this.navHostFragment = navHostFragment;
|
||||
}
|
||||
|
||||
public void syncWithBottomSheetBehavior(@NonNull BottomSheetBehavior<View> bottomSheetBehavior,
|
||||
@NonNull NavController navController) {
|
||||
navController.addOnDestinationChangedListener(
|
||||
(controller, destination, arguments) -> {
|
||||
// React to the user clicking one of these on bottom-navbar/drawer
|
||||
boolean isTarget = isTargetDestination(destination);
|
||||
int currentState = bottomSheetBehavior.getState();
|
||||
|
||||
if (isTarget && currentState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
});
|
||||
|
||||
NavigationUI.setupWithNavController(bottomNavigationView, navController);
|
||||
NavigationUI.setupWithNavController(navigationView, navController);
|
||||
}
|
||||
|
||||
@Contract(pure = true)
|
||||
private static boolean isTargetDestination(NavDestination destination) {
|
||||
int destId = destination.getId();
|
||||
return destId == R.id.homeFragment ||
|
||||
destId == R.id.libraryFragment ||
|
||||
destId == R.id.downloadFragment ||
|
||||
destId == R.id.albumCatalogueFragment ||
|
||||
destId == R.id.artistCatalogueFragment ||
|
||||
destId == R.id.genreCatalogueFragment ||
|
||||
destId == R.id.playlistCatalogueFragment;
|
||||
}
|
||||
|
||||
/*
|
||||
Clean public methods
|
||||
Removes the need to invoke the activity on the fragment
|
||||
*/
|
||||
|
||||
public void setBottomNavigationBarVisibility(boolean visible) {
|
||||
int visibility = visible
|
||||
? View.VISIBLE
|
||||
: View.GONE;
|
||||
bottomNavigationView.setVisibility(visibility);
|
||||
bottomNavigationViewFrame.setVisibility(visibility);
|
||||
}
|
||||
|
||||
public void setNavigationDrawerLock(boolean locked) {
|
||||
int mode = locked
|
||||
? DrawerLayout.LOCK_MODE_LOCKED_CLOSED
|
||||
: DrawerLayout.LOCK_MODE_UNLOCKED;
|
||||
drawerLayout.setDrawerLockMode(mode);
|
||||
}
|
||||
|
||||
public boolean isNavigationDrawerLocked() {
|
||||
return drawerLayout.getDrawerLockMode(navigationView) != DrawerLayout.LOCK_MODE_UNLOCKED;
|
||||
}
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public void toggleNavigationDrawerLockOnOrientationChange(
|
||||
AppCompatActivity activity) {
|
||||
|
||||
int orientation = activity.getResources().getConfiguration().orientation;
|
||||
boolean isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
|
||||
if (Preferences.getEnableDrawerOnPortrait()) {
|
||||
setNavigationDrawerLock(false);
|
||||
return;
|
||||
}
|
||||
setNavigationDrawerLock(!isLandscape);
|
||||
}
|
||||
|
||||
/*
|
||||
All of these are the "backward compatible" changes that don't break the assumption
|
||||
that everything was defined on the activity and is gobally available
|
||||
*/
|
||||
|
||||
@NonNull
|
||||
public BottomNavigationView getBottomNavigationView() {
|
||||
return bottomNavigationView;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public FrameLayout getBottomNavigationViewFrame() {
|
||||
return bottomNavigationViewFrame;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public DrawerLayout getDrawerLayout() {
|
||||
return drawerLayout;
|
||||
}
|
||||
|
||||
/*
|
||||
Auxiliar functions, could be moved somewhere else
|
||||
*/
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public void setSystemBarsVisibility(AppCompatActivity activity, boolean visibility) {
|
||||
WindowInsetsControllerCompat insetsController;
|
||||
Window window = activity.getWindow();
|
||||
View decorView = window.getDecorView();
|
||||
insetsController = new WindowInsetsControllerCompat(window, decorView);
|
||||
|
||||
if (visibility) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true);
|
||||
insetsController.show(WindowInsetsCompat.Type.navigationBars());
|
||||
insetsController.show(WindowInsetsCompat.Type.statusBars());
|
||||
insetsController.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_DEFAULT);
|
||||
} else {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false);
|
||||
insetsController.hide(WindowInsetsCompat.Type.navigationBars());
|
||||
insetsController.hide(WindowInsetsCompat.Type.statusBars());
|
||||
insetsController.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.cappielloantonio.tempo.provider;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.UriMatcher;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.cappielloantonio.tempo.BuildConfig;
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class AlbumArtContentProvider extends ContentProvider {
|
||||
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".albumart.provider";
|
||||
public static final String ALBUM_ART = "albumArt";
|
||||
private ExecutorService executor;
|
||||
|
||||
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||
|
||||
static {
|
||||
uriMatcher.addURI(AUTHORITY, "albumArt/*", 1);
|
||||
}
|
||||
|
||||
public static Uri contentUri(String artworkId) {
|
||||
return new Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(ALBUM_ART)
|
||||
.appendPath(artworkId)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
|
||||
Context context = getContext();
|
||||
String albumId = uri.getLastPathSegment();
|
||||
Uri artworkUri;
|
||||
|
||||
if (albumId != null && albumId.startsWith("ir_")) {
|
||||
String encodedUrl = albumId.substring("ir_".length());
|
||||
String decodedUrl = new String(Base64.decode(encodedUrl, Base64.URL_SAFE | Base64.NO_WRAP));
|
||||
artworkUri = Uri.parse(decodedUrl);
|
||||
} else {
|
||||
artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize()));
|
||||
}
|
||||
|
||||
try {
|
||||
// use pipe to communicate between background thread and caller of openFile()
|
||||
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
|
||||
ParcelFileDescriptor readSide = pipe[0];
|
||||
ParcelFileDescriptor writeSide = pipe[1];
|
||||
|
||||
// perform loading in background thread to avoid blocking UI
|
||||
executor.execute(() -> {
|
||||
try (OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeSide)) {
|
||||
|
||||
// request artwork from API using Glide
|
||||
File file = Glide.with(context)
|
||||
.asFile()
|
||||
.load(artworkUri)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
.submit()
|
||||
.get();
|
||||
|
||||
// copy artwork down pipe returned by ContentProvider
|
||||
try (InputStream in = new FileInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
writeSide.closeWithError("Failed to load image: " + e.getMessage());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
writeSide.closeWithError("Failed to load image: " + e.getMessage());
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
});
|
||||
|
||||
return readSide;
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new FileNotFoundException("Could not create pipe: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
executor = Executors.newFixedThreadPool(
|
||||
Math.max(2, Runtime.getRuntime().availableProcessors() / 2)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (executor != null) {
|
||||
executor.shutdown();
|
||||
try {
|
||||
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getType(@NonNull Uri uri) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@ package com.cappielloantonio.tempo.repository;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.interfaces.DecadesCallback;
|
||||
import com.cappielloantonio.tempo.interfaces.MediaCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumInfo;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.util.Constants.SeedType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
@@ -31,14 +33,22 @@ public class AlbumRepository {
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
|
||||
listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
|
||||
} else {
|
||||
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -195,29 +205,12 @@ public class AlbumRepository {
|
||||
return albumInfo;
|
||||
}
|
||||
|
||||
public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSimilarSongs2(album.getId(), count)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
|
||||
songs.addAll(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
|
||||
}
|
||||
|
||||
callback.onLoadMedia(songs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
callback.onLoadMedia(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
public MutableLiveData<List<Child>> getInstantMix(AlbumID3 album, int count) {
|
||||
// Delegate to the centralized SongRepository
|
||||
return new SongRepository().getInstantMix(album.getId(), SeedType.ALBUM, count);
|
||||
}
|
||||
|
||||
|
||||
public MutableLiveData<List<Integer>> getDecades() {
|
||||
MutableLiveData<List<Integer>> decades = new MutableLiveData<>();
|
||||
|
||||
@@ -228,7 +221,7 @@ public class AlbumRepository {
|
||||
@Override
|
||||
public void onLoadYear(int last) {
|
||||
if (first != -1 && last != -1) {
|
||||
List<Integer> decadeList = new ArrayList();
|
||||
List<Integer> decadeList = new ArrayList<>();
|
||||
|
||||
int startDecade = first - (first % 10);
|
||||
int lastDecade = last - (last % 10);
|
||||
@@ -289,4 +282,4 @@ public class AlbumRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,109 @@ package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import android.util.Log;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.interfaces.MediaCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
|
||||
import com.cappielloantonio.tempo.util.Constants.SeedType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class ArtistRepository {
|
||||
private final AlbumRepository albumRepository;
|
||||
|
||||
public ArtistRepository() {
|
||||
this.albumRepository = new AlbumRepository();
|
||||
}
|
||||
|
||||
public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) {
|
||||
Log.d("ArtistSync", "Getting albums for artist: " + artistId);
|
||||
|
||||
// Get the artist info first, which contains the albums
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getArtist(artistId)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getArtist() != null &&
|
||||
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
|
||||
|
||||
List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
|
||||
Log.d("ArtistSync", "Got albums directly: " + albums.size());
|
||||
|
||||
if (!albums.isEmpty()) {
|
||||
fetchAllAlbumSongsWithCallback(albums, callback);
|
||||
} else {
|
||||
Log.d("ArtistSync", "No albums found in artist response");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
} else {
|
||||
Log.d("ArtistSync", "Failed to get artist info");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchAllAlbumSongsWithCallback(List<AlbumID3> albums, ArtistSongsCallback callback) {
|
||||
if (albums == null || albums.isEmpty()) {
|
||||
Log.d("ArtistSync", "No albums to process");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
AtomicInteger remainingAlbums = new AtomicInteger(albums.size());
|
||||
Log.d("ArtistSync", "Processing " + albums.size() + " albums");
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
Log.d("ArtistSync", "Getting tracks for album: " + album.getName());
|
||||
MutableLiveData<List<Child>> albumTracks = albumRepository.getAlbumTracks(album.getId());
|
||||
albumTracks.observeForever(songs -> {
|
||||
Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album");
|
||||
if (songs != null) {
|
||||
allSongs.addAll(songs);
|
||||
}
|
||||
albumTracks.removeObservers(null);
|
||||
|
||||
int remaining = remainingAlbums.decrementAndGet();
|
||||
Log.d("ArtistSync", "Remaining albums: " + remaining);
|
||||
|
||||
if (remaining == 0) {
|
||||
Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size());
|
||||
callback.onSongsCollected(allSongs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public interface ArtistSongsCallback {
|
||||
void onSongsCollected(List<Child> songs);
|
||||
}
|
||||
|
||||
public MutableLiveData<List<ArtistID3>> getStarredArtists(boolean random, int size) {
|
||||
MutableLiveData<List<ArtistID3>> starredArtists = new MutableLiveData<>(new ArrayList<>());
|
||||
|
||||
@@ -65,7 +151,7 @@ public class ArtistRepository {
|
||||
|
||||
if(response.body().getSubsonicResponse().getArtists() != null && response.body().getSubsonicResponse().getArtists().getIndices() != null) {
|
||||
for (IndexID3 index : response.body().getSubsonicResponse().getArtists().getIndices()) {
|
||||
if(index != null && index.getArtists() != null) {
|
||||
if(index.getArtists() != null) {
|
||||
artists.addAll(index.getArtists());
|
||||
}
|
||||
}
|
||||
@@ -89,7 +175,7 @@ public class ArtistRepository {
|
||||
}
|
||||
|
||||
/*
|
||||
* Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...)
|
||||
* Method that returns essential artist information (cover, album number, etc.)
|
||||
*/
|
||||
public void getArtistInfo(List<ArtistID3> artists, MutableLiveData<List<ArtistID3>> list) {
|
||||
List<ArtistID3> liveArtists = list.getValue();
|
||||
@@ -203,26 +289,8 @@ public class ArtistRepository {
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getInstantMix(ArtistID3 artist, int count) {
|
||||
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSimilarSongs2(artist.getId(), count)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
|
||||
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return instantMix;
|
||||
// Delegate to the centralized SongRepository
|
||||
return new SongRepository().getInstantMix(artist.getId(), SeedType.ARTIST, count);
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getRandomSong(ArtistID3 artist, int count) {
|
||||
@@ -230,24 +298,42 @@ public class ArtistRepository {
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getTopSongs(artist.getName(), count)
|
||||
.getArtist(artist.getId())
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getTopSongs() != null && response.body().getSubsonicResponse().getTopSongs().getSongs() != null) {
|
||||
List<Child> songs = response.body().getSubsonicResponse().getTopSongs().getSongs();
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getArtist() != null &&
|
||||
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
|
||||
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
Collections.shuffle(songs);
|
||||
List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
|
||||
Log.d("ArtistRepository", "Got albums directly: " + albums.size());
|
||||
if (albums.isEmpty()) {
|
||||
Log.d("ArtistRepository", "No albums found in artist response");
|
||||
return;
|
||||
}
|
||||
|
||||
randomSongs.setValue(songs);
|
||||
Collections.shuffle(albums);
|
||||
int[] counts = albums.stream().mapToInt(AlbumID3::getSongCount).toArray();
|
||||
Arrays.parallelPrefix(counts, Integer::sum);
|
||||
int albumLimit = 0;
|
||||
int multiplier = 4; // get more than the limit so we can shuffle them
|
||||
while (albumLimit < albums.size() && counts[albumLimit] < count * multiplier)
|
||||
albumLimit++;
|
||||
Log.d("ArtistRepository", String.format("Retaining %d/%d albums", albumLimit, albums.size()));
|
||||
|
||||
fetchAllAlbumSongsWithCallback(albums.stream().limit(albumLimit).collect(Collectors.toList()), songs -> {
|
||||
Collections.shuffle(songs);
|
||||
randomSongs.setValue(songs.stream().limit(count).collect(Collectors.toList()));
|
||||
});
|
||||
} else {
|
||||
Log.d("ArtistRepository", "Failed to get artist info");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.d("ArtistRepository", "Error getting artist info: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.net.Uri;
|
||||
import android.view.View;
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
import com.cappielloantonio.tempo.model.Chronology;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.model.SessionMediaItem;
|
||||
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider;
|
||||
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
@@ -34,6 +35,7 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
|
||||
import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Genre;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
@@ -67,10 +69,20 @@ public class AutomotiveRepository {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
List<AlbumID3> albums = response.body().getSubsonicResponse().getAlbumList2().getAlbums();
|
||||
|
||||
// add by MFO
|
||||
// Hack for artist view
|
||||
if("alphabeticalByArtist".equals(type))for(AlbumID3 album : albums){
|
||||
String artistName = album.getArtist();
|
||||
String albumName = album.getName();
|
||||
album.setName(artistName);
|
||||
album.setArtist(albumName);
|
||||
}
|
||||
// end add by MFO
|
||||
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(album.getName())
|
||||
@@ -217,7 +229,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(album.getName())
|
||||
@@ -272,7 +284,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(artist.getName())
|
||||
@@ -397,7 +409,7 @@ public class AutomotiveRepository {
|
||||
List<Child> children = response.body().getSubsonicResponse().getIndexes().getChildren();
|
||||
|
||||
for (Child song : children) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(song.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(song.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(song.getTitle())
|
||||
@@ -451,7 +463,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (Child child : directory.getChildren()) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(child.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(child.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(child.getTitle())
|
||||
@@ -550,7 +562,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (PodcastEpisode episode : episodes) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(episode.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(episode.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(episode.getTitle())
|
||||
@@ -604,20 +616,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (InternetRadioStation radioStation : radioStations) {
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(radioStation.getName())
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
|
||||
.build();
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setMediaId(radioStation.getId())
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setUri(radioStation.getStreamUrl())
|
||||
.build();
|
||||
|
||||
mediaItems.add(mediaItem);
|
||||
mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation));
|
||||
}
|
||||
|
||||
setInternetRadioStationsMetadata(radioStations);
|
||||
@@ -687,7 +686,7 @@ public class AutomotiveRepository {
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(album.getName())
|
||||
@@ -791,7 +790,7 @@ public class AutomotiveRepository {
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getSearchingClient()
|
||||
.search3(query, 20, 20, 20)
|
||||
.search3(query, 20, 0, 20, 0, 20, 0)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
@@ -800,7 +799,7 @@ public class AutomotiveRepository {
|
||||
|
||||
if (response.body().getSubsonicResponse().getSearchResult3().getArtists() != null) {
|
||||
for (ArtistID3 artist : response.body().getSubsonicResponse().getSearchResult3().getArtists()) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(artist.getName())
|
||||
@@ -822,7 +821,7 @@ public class AutomotiveRepository {
|
||||
|
||||
if (response.body().getSubsonicResponse().getSearchResult3().getAlbums() != null) {
|
||||
for (AlbumID3 album : response.body().getSubsonicResponse().getSearchResult3().getAlbums()) {
|
||||
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
|
||||
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
|
||||
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(album.getName())
|
||||
@@ -954,6 +953,116 @@ public class AutomotiveRepository {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getGenres(String prefix) {
|
||||
final SettableFuture<LibraryResult<ImmutableList<MediaItem>>> listenableFuture = SettableFuture.create();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getGenres()
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getGenres() != null && response.body().getSubsonicResponse().getGenres().getGenres() != null) {
|
||||
List<Genre> genres = response.body().getSubsonicResponse().getGenres().getGenres();
|
||||
|
||||
// Sort genres alphabetically by name
|
||||
genres.sort((g1, g2) -> {
|
||||
String name1 = g1.getGenre() != null ? g1.getGenre() : "";
|
||||
String name2 = g2.getGenre() != null ? g2.getGenre() : "";
|
||||
return name1.compareToIgnoreCase(name2);
|
||||
});
|
||||
|
||||
List<MediaItem> mediaItems = new ArrayList<>();
|
||||
|
||||
for (Genre genre : genres) {
|
||||
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setTitle(genre.getGenre())
|
||||
.setIsBrowsable(true)
|
||||
.setIsPlayable(false)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
|
||||
.build();
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setMediaId(prefix + genre.getGenre())
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setUri("")
|
||||
.build();
|
||||
|
||||
mediaItems.add(mediaItem);
|
||||
}
|
||||
|
||||
LibraryResult<ImmutableList<MediaItem>> libraryResult = LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), null);
|
||||
|
||||
listenableFuture.set(libraryResult);
|
||||
} else {
|
||||
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
listenableFuture.setException(t);
|
||||
}
|
||||
});
|
||||
|
||||
return listenableFuture;
|
||||
}
|
||||
|
||||
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getSongsByGenre(String genre, int count, boolean shuffle) {
|
||||
final SettableFuture<LibraryResult<ImmutableList<MediaItem>>> listenableFuture = SettableFuture.create();
|
||||
|
||||
Call<ApiResponse> call;
|
||||
if (shuffle) {
|
||||
call = App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getRandomSongs(count, null, null, genre);
|
||||
} else {
|
||||
call = App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getSongsByGenre(genre, count, 0);
|
||||
}
|
||||
|
||||
call.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
List<com.cappielloantonio.tempo.subsonic.models.Child> songs;
|
||||
if (shuffle) {
|
||||
songs = response.body().getSubsonicResponse().getRandomSongs() != null
|
||||
? response.body().getSubsonicResponse().getRandomSongs().getSongs()
|
||||
: null;
|
||||
} else {
|
||||
songs = response.body().getSubsonicResponse().getSongsByGenre() != null
|
||||
? response.body().getSubsonicResponse().getSongsByGenre().getSongs()
|
||||
: null;
|
||||
}
|
||||
|
||||
if (songs != null) {
|
||||
setChildrenMetadata(songs);
|
||||
List<MediaItem> mediaItems = MappingUtil.mapMediaItems(songs);
|
||||
LibraryResult<ImmutableList<MediaItem>> libraryResult = LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), null);
|
||||
listenableFuture.set(libraryResult);
|
||||
} else {
|
||||
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
|
||||
}
|
||||
} else {
|
||||
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
listenableFuture.setException(t);
|
||||
}
|
||||
});
|
||||
|
||||
return listenableFuture;
|
||||
}
|
||||
|
||||
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getSongsByGenre(String genre, int count) {
|
||||
return getSongsByGenre(genre, count, false);
|
||||
}
|
||||
|
||||
private static class GetMediaItemThreadSafe implements Runnable {
|
||||
private final SessionMediaItemDao sessionMediaItemDao;
|
||||
private final String id;
|
||||
|
||||
@@ -18,6 +18,20 @@ public class DownloadRepository {
|
||||
return downloadDao.getAll();
|
||||
}
|
||||
|
||||
public List<Download> getAllDownloads() {
|
||||
GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
|
||||
Thread thread = new Thread(getDownloads);
|
||||
thread.start();
|
||||
|
||||
try {
|
||||
thread.join();
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return getDownloads.getDownloads();
|
||||
}
|
||||
|
||||
public Download getDownload(String id) {
|
||||
Download download = null;
|
||||
|
||||
@@ -35,6 +49,24 @@ public class DownloadRepository {
|
||||
return download;
|
||||
}
|
||||
|
||||
private static class GetAllDownloadsThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private List<Download> downloads;
|
||||
|
||||
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
|
||||
this.downloadDao = downloadDao;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloads = downloadDao.getAllSync();
|
||||
}
|
||||
|
||||
public List<Download> getDownloads() {
|
||||
return downloads;
|
||||
}
|
||||
}
|
||||
|
||||
private static class GetDownloadThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final String id;
|
||||
@@ -143,6 +175,12 @@ public class DownloadRepository {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void delete(List<String> ids) {
|
||||
DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
|
||||
Thread thread = new Thread(delete);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static class DeleteThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final String id;
|
||||
@@ -157,4 +195,19 @@ public class DownloadRepository {
|
||||
downloadDao.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeleteMultipleThreadSafe implements Runnable {
|
||||
private final DownloadDao downloadDao;
|
||||
private final List<String> ids;
|
||||
|
||||
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
|
||||
this.downloadDao = downloadDao;
|
||||
this.ids = ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloadDao.deleteByIds(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||
import com.cappielloantonio.tempo.database.dao.LyricsDao;
|
||||
import com.cappielloantonio.tempo.model.LyricsCache;
|
||||
|
||||
public class LyricsRepository {
|
||||
private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao();
|
||||
|
||||
public LyricsCache getLyrics(String songId) {
|
||||
GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId);
|
||||
Thread thread = new Thread(getLyricsThreadSafe);
|
||||
thread.start();
|
||||
|
||||
try {
|
||||
thread.join();
|
||||
return getLyricsThreadSafe.getLyrics();
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public LiveData<LyricsCache> observeLyrics(String songId) {
|
||||
return lyricsDao.observeOne(songId);
|
||||
}
|
||||
|
||||
public void insert(LyricsCache lyricsCache) {
|
||||
InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache);
|
||||
Thread thread = new Thread(insert);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void delete(String songId) {
|
||||
DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId);
|
||||
Thread thread = new Thread(delete);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
private static class GetLyricsThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final String songId;
|
||||
private LyricsCache lyricsCache;
|
||||
|
||||
public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.songId = songId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsCache = lyricsDao.getOne(songId);
|
||||
}
|
||||
|
||||
public LyricsCache getLyrics() {
|
||||
return lyricsCache;
|
||||
}
|
||||
}
|
||||
|
||||
private static class InsertThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final LyricsCache lyricsCache;
|
||||
|
||||
public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.lyricsCache = lyricsCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsDao.insert(lyricsCache);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeleteThreadSafe implements Runnable {
|
||||
private final LyricsDao lyricsDao;
|
||||
private final String songId;
|
||||
|
||||
public DeleteThreadSafe(LyricsDao lyricsDao, String songId) {
|
||||
this.lyricsDao = lyricsDao;
|
||||
this.songId = songId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
lyricsDao.delete(songId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import static android.provider.Settings.System.getString;
|
||||
|
||||
import android.provider.Settings;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
@@ -26,8 +26,45 @@ import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class PlaylistRepository {
|
||||
private static final MutableLiveData<Boolean> playlistUpdateTrigger = new MutableLiveData<>();
|
||||
|
||||
public LiveData<Boolean> getPlaylistUpdateTrigger() {
|
||||
return playlistUpdateTrigger;
|
||||
}
|
||||
|
||||
public void notifyPlaylistChanged() {
|
||||
playlistUpdateTrigger.postValue(true);
|
||||
refreshAllPlaylists();
|
||||
}
|
||||
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao();
|
||||
private static final MutableLiveData<List<Playlist>> allPlaylistsLiveData = new MutableLiveData<>();
|
||||
|
||||
public LiveData<List<Playlist>> getAllPlaylists(LifecycleOwner owner) {
|
||||
refreshAllPlaylists();
|
||||
return allPlaylistsLiveData;
|
||||
}
|
||||
|
||||
public void refreshAllPlaylists() {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.getPlaylists()
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
|
||||
List<Playlist> playlists = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
|
||||
allPlaylistsLiveData.postValue(playlists);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Playlist>> getPlaylists(boolean random, int size) {
|
||||
MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>());
|
||||
|
||||
@@ -80,21 +117,87 @@ public class PlaylistRepository {
|
||||
return listLivePlaylistSongs;
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
|
||||
public MutableLiveData<Playlist> getPlaylist(String id) {
|
||||
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, true, songsId, null)
|
||||
.getPlaylist(id)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getPlaylist() != null) {
|
||||
playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist());
|
||||
} else {
|
||||
playlistLiveData.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
|
||||
playlistLiveData.setValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
return playlistLiveData;
|
||||
}
|
||||
|
||||
public interface AddToPlaylistCallback {
|
||||
void onSuccess();
|
||||
void onFailure();
|
||||
void onAllSkipped();
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic, AddToPlaylistCallback callback) {
|
||||
android.util.Log.d("PlaylistRepository", "addSongToPlaylist: id=" + playlistId + ", songs=" + songsId);
|
||||
if (songsId.isEmpty()) {
|
||||
if (callback != null) callback.onAllSkipped();
|
||||
} else{
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, playlistVisibilityIsPublic, songsId, null)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful()) notifyPlaylistChanged();
|
||||
if (callback != null) callback.onSuccess();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
if (callback != null) callback.onFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void removeSongFromPlaylist(String playlistId, int index, AddToPlaylistCallback callback) {
|
||||
ArrayList<Integer> indexes = new ArrayList<>();
|
||||
indexes.add(index);
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, true, null, indexes)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful()) notifyPlaylistChanged();
|
||||
if (callback != null) {
|
||||
if (response.isSuccessful()) callback.onSuccess();
|
||||
else callback.onFailure();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
if (callback != null) callback.onFailure();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
|
||||
addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null);
|
||||
}
|
||||
|
||||
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
|
||||
@@ -104,7 +207,7 @@ public class PlaylistRepository {
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
if (response.isSuccessful()) notifyPlaylistChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,35 +220,43 @@ public class PlaylistRepository {
|
||||
public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.deletePlaylist(playlistId)
|
||||
.updatePlaylist(playlistId, name, true, null, null)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
createPlaylist(null, name, songsId);
|
||||
if (response.isSuccessful()) {
|
||||
// After renaming, we need to handle the song list update.
|
||||
// Subsonic doesn't have a "replace all songs" in updatePlaylist.
|
||||
// So we might still need to recreate if the songs changed significantly,
|
||||
// but if we just renamed, we should update the local pinned database.
|
||||
updateLocalPinnedPlaylistName(playlistId, name);
|
||||
notifyPlaylistChanged();
|
||||
}
|
||||
|
||||
// If songsId is provided, we might want to re-sync them.
|
||||
// For now, let's at least fix the name duplication issue.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList<String> songIdToAdd, ArrayList<Integer> songIndexToRemove) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
private void updateLocalPinnedPlaylistName(String id, String newName) {
|
||||
new Thread(() -> {
|
||||
List<Playlist> pinned = playlistDao.getAllSync();
|
||||
if (pinned != null) {
|
||||
for (Playlist p : pinned) {
|
||||
if (p.getId().equals(id)) {
|
||||
p.setName(newName);
|
||||
playlistDao.insert(p); // Replace strategy will update it
|
||||
break;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void deletePlaylist(String playlistId) {
|
||||
@@ -155,7 +266,7 @@ public class PlaylistRepository {
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
if (response.isSuccessful()) notifyPlaylistChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -183,6 +294,49 @@ public class PlaylistRepository {
|
||||
thread.start();
|
||||
}
|
||||
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
public void updatePinnedPlaylists() {
|
||||
updatePinnedPlaylists(null);
|
||||
}
|
||||
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
public void updatePinnedPlaylists(List<String> forceIds) {
|
||||
new Thread(() -> {
|
||||
List<Playlist> pinned = playlistDao.getAllSync();
|
||||
if (pinned != null && !pinned.isEmpty()) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.getPlaylists()
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlaylists() != null) {
|
||||
List<Playlist> remotes = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
|
||||
new Thread(() -> {
|
||||
for (Playlist p : pinned) {
|
||||
for (Playlist r : remotes) {
|
||||
if (p.getId().equals(r.getId())) {
|
||||
p.setName(r.getName());
|
||||
p.setSongCount(r.getSongCount());
|
||||
p.setDuration(r.getDuration());
|
||||
p.setCoverArtId(r.getCoverArtId());
|
||||
playlistDao.insert(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private static class InsertThreadSafe implements Runnable {
|
||||
private final PlaylistDao playlistDao;
|
||||
private final Playlist playlist;
|
||||
|
||||
@@ -66,88 +66,33 @@ public class PodcastRepository {
|
||||
return liveNewestPodcastEpisodes;
|
||||
}
|
||||
|
||||
public void refreshPodcasts() {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> refreshPodcasts() {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getPodcastClient()
|
||||
.refreshPodcasts()
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.refreshPodcasts();
|
||||
}
|
||||
|
||||
public void createPodcastChannel(String url) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> createPodcastChannel(String url) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getPodcastClient()
|
||||
.createPodcastChannel(url)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.createPodcastChannel(url);
|
||||
}
|
||||
|
||||
public void deletePodcastChannel(String channelId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> deletePodcastChannel(String channelId) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getPodcastClient()
|
||||
.deletePodcastChannel(channelId)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.deletePodcastChannel(channelId);
|
||||
}
|
||||
|
||||
public void deletePodcastEpisode(String episodeId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> deletePodcastEpisode(String episodeId) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getPodcastClient()
|
||||
.deletePodcastEpisode(episodeId)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.deletePodcastEpisode(episodeId);
|
||||
}
|
||||
|
||||
public void downloadPodcastEpisode(String episodeId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> downloadPodcastEpisode(String episodeId) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getPodcastClient()
|
||||
.downloadPodcastEpisode(episodeId)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.downloadPodcastEpisode(episodeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||
@@ -52,6 +55,8 @@ public class QueueRepository {
|
||||
public MutableLiveData<PlayQueue> getPlayQueue() {
|
||||
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
|
||||
|
||||
Log.d(TAG, "Getting play queue from server...");
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBookmarksClient()
|
||||
.getPlayQueue()
|
||||
@@ -59,12 +64,19 @@ public class QueueRepository {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
|
||||
playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
|
||||
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
|
||||
Log.d(TAG, "Server returned play queue with " +
|
||||
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
|
||||
playQueue.setValue(serverQueue);
|
||||
} else {
|
||||
Log.d(TAG, "Server returned no play queue");
|
||||
playQueue.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "Failed to get play queue", t);
|
||||
playQueue.setValue(null);
|
||||
}
|
||||
});
|
||||
@@ -73,18 +85,24 @@ public class QueueRepository {
|
||||
}
|
||||
|
||||
public void savePlayQueue(List<String> ids, String current, long position) {
|
||||
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBookmarksClient()
|
||||
.savePlayQueue(ids, current, position)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Play queue saved successfully");
|
||||
} else {
|
||||
Log.d(TAG, "Play queue save failed with code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.e(TAG, "Play queue save failed", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +139,14 @@ public class QueueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMediaInQueue(List<Queue> queue, Child media) {
|
||||
if (queue == null || media == null) return false;
|
||||
return queue.stream().anyMatch(queueItem ->
|
||||
queueItem != null && media.getId() != null &&
|
||||
queueItem.getId().equals(media.getId())
|
||||
);
|
||||
}
|
||||
|
||||
public void insertAll(List<Child> toAdd, boolean reset, int afterIndex) {
|
||||
try {
|
||||
List<Queue> media = new ArrayList<>();
|
||||
@@ -134,8 +160,14 @@ public class QueueRepository {
|
||||
media = getMediaThreadSafe.getMedia();
|
||||
}
|
||||
|
||||
for (int i = 0; i < toAdd.size(); i++) {
|
||||
Queue queueItem = new Queue(toAdd.get(i));
|
||||
List<Child> filteredToAdd = toAdd;
|
||||
final List<Queue> finalMedia = media;
|
||||
filteredToAdd = toAdd.stream()
|
||||
.filter(child -> !isMediaInQueue(finalMedia, child))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (int i = 0; i < filteredToAdd.size(); i++) {
|
||||
Queue queueItem = new Queue(filteredToAdd.get(i));
|
||||
media.add(afterIndex + i, queueItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,54 +38,22 @@ public class RadioRepository {
|
||||
return radioStation;
|
||||
}
|
||||
|
||||
public void createInternetRadioStation(String name, String streamURL, String homepageURL) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> createInternetRadioStation(String name, String streamURL, String homepageURL) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getInternetRadioClient()
|
||||
.createInternetRadioStation(streamURL, name, homepageURL)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.createInternetRadioStation(streamURL, name, homepageURL);
|
||||
}
|
||||
|
||||
public void updateInternetRadioStation(String id, String name, String streamURL, String homepageURL) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> updateInternetRadioStation(String id, String name, String streamURL, String homepageURL) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getInternetRadioClient()
|
||||
.updateInternetRadioStation(id, streamURL, name, homepageURL)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.updateInternetRadioStation(id, streamURL, name, homepageURL);
|
||||
}
|
||||
|
||||
public void deleteInternetRadioStation(String id) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
public Call<ApiResponse> deleteInternetRadioStation(String id) {
|
||||
return App.getSubsonicClientInstance(false)
|
||||
.getInternetRadioClient()
|
||||
.deleteInternetRadioStation(id)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
.deleteInternetRadioStation(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
|
||||
import com.cappielloantonio.tempo.model.RecentSearch;
|
||||
@@ -11,12 +16,18 @@ import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PlaylistWithSongs;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SearchResult3;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.ui.fragment.SearchFragment;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -30,7 +41,7 @@ public class SearchingRepository {
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getSearchingClient()
|
||||
.search3(query, 20, 20, 20)
|
||||
.search3(query, 20, 0, 20, 0, 20, 0)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
@@ -48,12 +59,63 @@ public class SearchingRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
public MutableLiveData<SearchResult3> search3(String query) {
|
||||
@UnstableApi
|
||||
public MutableLiveData<SearchResult3> search3(SearchFragment sf, String query) {
|
||||
MutableLiveData<SearchResult3> result = new MutableLiveData<>();
|
||||
|
||||
Executors.newSingleThreadExecutor().execute(() -> {
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
int offset = 0;
|
||||
int limit = 1000;
|
||||
boolean hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
Response<ApiResponse> response = App.getSubsonicClientInstance(false)
|
||||
.getSearchingClient()
|
||||
.search3(query, limit, offset, 0, 0, 0, 0)
|
||||
.execute();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
SearchResult3 tmp = response.body().getSubsonicResponse().getSearchResult3();
|
||||
if (tmp != null && tmp.getSongs() != null && !tmp.getSongs().isEmpty()) {
|
||||
List<Child> fetchedSongs = tmp.getSongs();
|
||||
allSongs.addAll(fetchedSongs);
|
||||
|
||||
offset += fetchedSongs.size();
|
||||
hasMore = fetchedSongs.size() == limit;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
PlaylistWithSongs pws = new PlaylistWithSongs("allsongs", allSongs);
|
||||
pws.setName(sf.getView().getContext().getString(R.string.search_all_songs, String.valueOf(allSongs.size())));
|
||||
pws.setSongCount(allSongs.size());
|
||||
List<Playlist> lpws = new ArrayList<>();
|
||||
lpws.add(pws);
|
||||
long duration = 0;
|
||||
for (Child song: allSongs) {
|
||||
if (song != null && song.getDuration() != null) {
|
||||
duration += song.getDuration();
|
||||
}
|
||||
}
|
||||
pws.setDuration(duration);
|
||||
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
sf.updateUI(lpws);
|
||||
});
|
||||
});
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getSearchingClient()
|
||||
.search3(query, 20, 20, 20)
|
||||
.search3(query, 20, 0, 20, 0, 20, 0)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
@@ -76,7 +138,7 @@ public class SearchingRepository {
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getSearchingClient()
|
||||
.search3(query, 5, 5, 5)
|
||||
.search3(query, 5, 0, 5, 0, 5, 0)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
@@ -186,7 +248,12 @@ public class SearchingRepository {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
recent = recentSearchDao.getRecent();
|
||||
if(Preferences.isSearchSortingChronologicallyEnabled()){
|
||||
recent = recentSearchDao.getRecent();
|
||||
}
|
||||
else {
|
||||
recent = recentSearchDao.getAlpha();
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getRecent() {
|
||||
|
||||
@@ -41,7 +41,7 @@ public class SharingRepository {
|
||||
public MutableLiveData<Share> createShare(String id, String description, Long expires) {
|
||||
MutableLiveData<Share> share = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
App.getSubsonicPublicClientInstance(false)
|
||||
.getSharingClient()
|
||||
.createShare(id, description, expires)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@@ -64,7 +64,7 @@ public class SharingRepository {
|
||||
}
|
||||
|
||||
public void updateShare(String id, String description, Long expires) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
App.getSubsonicPublicClientInstance(false)
|
||||
.getSharingClient()
|
||||
.updateShare(id, description, expires)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SubsonicResponse;
|
||||
import com.cappielloantonio.tempo.util.Constants.SeedType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SongRepository {
|
||||
|
||||
private static final String TAG = "SongRepository";
|
||||
|
||||
public interface MediaCallbackInternal {
|
||||
void onSongsAvailable(List<Child> songs);
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getStarredSongs(boolean random, int size) {
|
||||
MutableLiveData<List<Child>> starredSongs = new MutableLiveData<>(Collections.emptyList());
|
||||
|
||||
@@ -42,25 +54,202 @@ public class SongRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
|
||||
return starredSongs;
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getInstantMix(String id, int count) {
|
||||
/**
|
||||
* Used by ViewModels. Updates the LiveData list incrementally as songs are found.
|
||||
*/
|
||||
public MutableLiveData<List<Child>> getInstantMix(String id, SeedType type, int count) {
|
||||
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(new ArrayList<>());
|
||||
Set<String> trackIds = new HashSet<>();
|
||||
|
||||
performSmartMix(id, type, count, songs -> {
|
||||
List<Child> current = instantMix.getValue();
|
||||
if (current != null) {
|
||||
for (Child s : songs) {
|
||||
if (!trackIds.contains(s.getId())) {
|
||||
current.add(s);
|
||||
trackIds.add(s.getId());
|
||||
}
|
||||
}
|
||||
|
||||
if (current.size() < count / 2) {
|
||||
fetchSimilarOnly(id, count, remainder -> {
|
||||
for (Child r : remainder) {
|
||||
if (!trackIds.contains(r.getId())) {
|
||||
current.add(r);
|
||||
trackIds.add(r.getId());
|
||||
}
|
||||
}
|
||||
instantMix.postValue(current);
|
||||
});
|
||||
} else {
|
||||
instantMix.postValue(current);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return instantMix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overloaded method used by other Repositories
|
||||
*/
|
||||
public void getInstantMix(String id, SeedType type, int count, MediaCallbackInternal callback) {
|
||||
new MediaCallbackAccumulator(callback, count).start(id, type);
|
||||
}
|
||||
|
||||
private class MediaCallbackAccumulator {
|
||||
private final MediaCallbackInternal originalCallback;
|
||||
private final int targetCount;
|
||||
private final List<Child> accumulatedSongs = new ArrayList<>();
|
||||
private final Set<String> trackIds = new HashSet<>();
|
||||
private boolean isComplete = false;
|
||||
|
||||
MediaCallbackAccumulator(MediaCallbackInternal callback, int count) {
|
||||
this.originalCallback = callback;
|
||||
this.targetCount = count;
|
||||
}
|
||||
|
||||
void start(String id, SeedType type) {
|
||||
performSmartMix(id, type, targetCount, this::onBatchReceived);
|
||||
}
|
||||
|
||||
private void onBatchReceived(List<Child> batch) {
|
||||
if (isComplete || batch == null || batch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int added = 0;
|
||||
for (Child song : batch) {
|
||||
if (!trackIds.contains(song.getId()) && accumulatedSongs.size() < targetCount) {
|
||||
trackIds.add(song.getId());
|
||||
accumulatedSongs.add(song);
|
||||
added++;
|
||||
}
|
||||
}
|
||||
|
||||
if (accumulatedSongs.size() >= targetCount) {
|
||||
originalCallback.onSongsAvailable(new ArrayList<>(accumulatedSongs));
|
||||
isComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void performSmartMix(final String id, final SeedType type, final int count, final MediaCallbackInternal callback) {
|
||||
switch (type) {
|
||||
case ARTIST:
|
||||
fetchSimilarByArtist(id, count, callback);
|
||||
break;
|
||||
case ALBUM:
|
||||
fetchAlbumSongs(id, count, callback);
|
||||
break;
|
||||
case TRACK:
|
||||
fetchSingleTrackThenSimilar(id, count, callback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void fetchAlbumSongs(String albumId, int count, MediaCallbackInternal callback) {
|
||||
App.getSubsonicClientInstance(false).getBrowsingClient().getAlbum(albumId).enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getAlbum() != null) {
|
||||
List<Child> albumSongs = response.body().getSubsonicResponse().getAlbum().getSongs();
|
||||
if (albumSongs != null && !albumSongs.isEmpty()) {
|
||||
int fromAlbum = Math.min(count, albumSongs.size());
|
||||
List<Child> limitedAlbumSongs = albumSongs.subList(0, fromAlbum);
|
||||
callback.onSongsAvailable(new ArrayList<>(limitedAlbumSongs));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "fetchAlbumSongsThenSimilar.onFailure()", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchSimilarByArtist(String artistId, final int count, final MediaCallbackInternal callback) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSimilarSongs2(artistId, count)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> similar = extractSongs(response, "similarSongs2");
|
||||
Log.d(TAG, "fetchSimilarByArtist.onResponse() - similar songs: " + similar.size());
|
||||
|
||||
if (!similar.isEmpty()) {
|
||||
List<Child> limitedSimilar = similar.subList(0, Math.min(count, similar.size()));
|
||||
callback.onSongsAvailable(limitedSimilar);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "fetchSimilarByArtist.onFailure()", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchSingleTrackThenSimilar(String trackId, int count, MediaCallbackInternal callback) {
|
||||
App.getSubsonicClientInstance(false).getBrowsingClient().getSong(trackId).enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
Child song = response.body().getSubsonicResponse().getSong();
|
||||
if (song != null) {
|
||||
callback.onSongsAvailable(Collections.singletonList(song));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "fetchSingleTrackThenSimilar.onFailure()", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchSimilarOnly(String id, int count, MediaCallbackInternal callback) {
|
||||
App.getSubsonicClientInstance(false).getBrowsingClient().getSimilarSongs(id, count).enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = extractSongs(response, "similarSongs");
|
||||
if (!songs.isEmpty()) {
|
||||
int limit = Math.min(count, songs.size());
|
||||
callback.onSongsAvailable(songs.subList(0, limit));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "fetchSimilarOnly.onFailure()", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public MutableLiveData<List<Child>> getContinuousMix(String id, int count) {
|
||||
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSimilarSongs2(id, count)
|
||||
.getSimilarSongs(id, count)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
|
||||
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs() != null) {
|
||||
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs().getSongs());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,161 +262,128 @@ public class SongRepository {
|
||||
return instantMix;
|
||||
}
|
||||
|
||||
private List<Child> extractSongs(Response<ApiResponse> response, String type) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
SubsonicResponse res = response.body().getSubsonicResponse();
|
||||
List<Child> list = null;
|
||||
if (type.equals("similarSongs") && res.getSimilarSongs() != null) {
|
||||
list = res.getSimilarSongs().getSongs();
|
||||
} else if (type.equals("similarSongs2") && res.getSimilarSongs2() != null) {
|
||||
list = res.getSimilarSongs2().getSongs();
|
||||
}
|
||||
return (list != null) ? list : new ArrayList<>();
|
||||
}
|
||||
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getRandomSample(int number, Integer fromYear, Integer toYear) {
|
||||
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getRandomSongs(number, fromYear, toYear)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) {
|
||||
songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs());
|
||||
}
|
||||
|
||||
randomSongsSample.setValue(songs);
|
||||
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
|
||||
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
|
||||
if (returned != null) {
|
||||
songs.addAll(returned);
|
||||
}
|
||||
}
|
||||
randomSongsSample.setValue(songs);
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
return randomSongsSample;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) {
|
||||
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear, genre).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
|
||||
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
|
||||
if (returned != null) {
|
||||
songs.addAll(returned);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
randomSongsSample.setValue(songs);
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
return randomSongsSample;
|
||||
}
|
||||
|
||||
public void scrobble(String id, boolean submission) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getMediaAnnotationClient()
|
||||
.scrobble(id, submission)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
}
|
||||
|
||||
public void setRating(String id, int rating) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getMediaAnnotationClient()
|
||||
.setRating(id, rating)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getSongsByGenre(String id, int page) {
|
||||
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getSongsByGenre(id, 100, 100 * page)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
|
||||
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 100, 100 * page).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
|
||||
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
|
||||
}
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
return songsByGenre;
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getSongsByGenres(ArrayList<String> genresId) {
|
||||
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
|
||||
|
||||
for (String id : genresId)
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getSongsByGenre(id, 500, 0)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
|
||||
songs.addAll(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
|
||||
}
|
||||
|
||||
songsByGenre.setValue(songs);
|
||||
for (String id : genresId) {
|
||||
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 500, 0).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
|
||||
List<Child> returned = response.body().getSubsonicResponse().getSongsByGenre().getSongs();
|
||||
if (returned != null) {
|
||||
songs.addAll(returned);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
songsByGenre.setValue(songs);
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
}
|
||||
return songsByGenre;
|
||||
}
|
||||
|
||||
public MutableLiveData<Child> getSong(String id) {
|
||||
MutableLiveData<Child> song = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSong(id)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
song.setValue(response.body().getSubsonicResponse().getSong());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
App.getSubsonicClientInstance(false).getBrowsingClient().getSong(id).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null) {
|
||||
song.setValue(response.body().getSubsonicResponse().getSong());
|
||||
}
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
return song;
|
||||
}
|
||||
|
||||
public MutableLiveData<String> getSongLyrics(Child song) {
|
||||
MutableLiveData<String> lyrics = new MutableLiveData<>(null);
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getMediaRetrievalClient()
|
||||
.getLyrics(song.getArtist(), song.getTitle())
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
|
||||
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
App.getSubsonicClientInstance(false).getMediaRetrievalClient().getLyrics(song.getArtist(), song.getTitle()).enqueue(new Callback<ApiResponse>() {
|
||||
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
|
||||
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
|
||||
}
|
||||
}
|
||||
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
|
||||
});
|
||||
return lyrics;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
||||
import androidx.media3.session.*
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import androidx.media3.extractor.metadata.icy.IcyInfo
|
||||
import androidx.media3.extractor.metadata.id3.TextInformationFrame
|
||||
import androidx.media3.extractor.metadata.vorbis.VorbisComment
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.repository.QueueRepository
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||
import com.cappielloantonio.tempo.util.*
|
||||
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ScheduledExecutorService
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val TAG = "BaseMediaService"
|
||||
|
||||
@UnstableApi
|
||||
open class BaseMediaService : MediaLibraryService() {
|
||||
companion object {
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||
"android.media3.session.demo.SHUFFLE_ON"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||
"android.media3.session.demo.SHUFFLE_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
|
||||
"android.media3.session.demo.REPEAT_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
|
||||
"android.media3.session.demo.REPEAT_ONE"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
|
||||
"android.media3.session.demo.REPEAT_ALL"
|
||||
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
||||
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
|
||||
}
|
||||
|
||||
protected lateinit var exoplayer: ExoPlayer
|
||||
protected lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var networkCallback: CustomNetworkCallback
|
||||
private lateinit var equalizerManager: EqualizerManager
|
||||
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
||||
private var widgetUpdateScheduled = false
|
||||
private val widgetUpdateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
val player = mediaLibrarySession.player
|
||||
if (!player.isPlaying) {
|
||||
widgetUpdateScheduled = false
|
||||
return
|
||||
}
|
||||
updateWidget(player)
|
||||
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private var radioHeaderCheckScheduled = false
|
||||
private var radioHeaderCheckFuture: ScheduledFuture<*>? = null
|
||||
private val radioHeaderCheckRunnable = Runnable {
|
||||
checkRadioHttpHeaders()
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
open fun playerInitHook() {
|
||||
initializeExoPlayer()
|
||||
initializeMediaLibrarySession(exoplayer)
|
||||
initializePlayerListener(exoplayer)
|
||||
setPlayer(null, exoplayer)
|
||||
}
|
||||
|
||||
open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
|
||||
return CustomMediaLibrarySessionCallback(baseContext)
|
||||
}
|
||||
|
||||
fun updateMediaItems(player: Player) {
|
||||
Log.d(TAG, "update items")
|
||||
val n = player.mediaItemCount
|
||||
val k = player.currentMediaItemIndex
|
||||
val current = player.currentPosition
|
||||
val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) }
|
||||
player.clearMediaItems()
|
||||
player.setMediaItems(items, k, current)
|
||||
}
|
||||
|
||||
fun restorePlayerFromQueue(player: Player) {
|
||||
if (player.mediaItemCount > 0) return
|
||||
|
||||
val queueRepository = QueueRepository()
|
||||
val storedQueue = queueRepository.media
|
||||
if (storedQueue.isNullOrEmpty()) return
|
||||
|
||||
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
|
||||
if (mediaItems.isEmpty()) return
|
||||
|
||||
val lastIndex = try {
|
||||
queueRepository.lastPlayedMediaIndex
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}.coerceIn(0, mediaItems.size - 1)
|
||||
|
||||
val lastPosition = try {
|
||||
queueRepository.lastPlayedMediaTimestamp
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}.let { if (it < 0L) 0L else it }
|
||||
|
||||
player.setMediaItems(mediaItems, lastIndex, lastPosition)
|
||||
player.prepare()
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
private var lastRadioArtist: String? = null
|
||||
private var lastRadioTitle: String? = null
|
||||
|
||||
fun initializePlayerListener(player: Player) {
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
Log.d(TAG, "onMediaItemTransition" + player.currentMediaItemIndex)
|
||||
if (mediaItem == null) return
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
||||
MediaManager.setLastPlayedTimestamp(mediaItem)
|
||||
}
|
||||
|
||||
// Restart header checks for radio streams when media item changes
|
||||
val mediaType = mediaItem.mediaMetadata.extras?.getString("type")
|
||||
if (mediaType == Constants.MEDIA_TYPE_RADIO && player.isPlaying) {
|
||||
stopRadioHeaderChecks()
|
||||
scheduleRadioHeaderChecks()
|
||||
} else if (mediaType != Constants.MEDIA_TYPE_RADIO) {
|
||||
stopRadioHeaderChecks()
|
||||
}
|
||||
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex)
|
||||
ReplayGainUtil.setReplayGain(player, tracks)
|
||||
val currentMediaItem = player.currentMediaItem
|
||||
if (currentMediaItem != null) {
|
||||
val item = MappingUtil.mapMediaItem(currentMediaItem)
|
||||
if (item.mediaMetadata.extras != null)
|
||||
MediaManager.scrobble(item, false)
|
||||
|
||||
if (player.nextMediaItemIndex == C.INDEX_UNSET) {
|
||||
val browserFuture = MediaBrowser.Builder(
|
||||
this@BaseMediaService,
|
||||
SessionToken(this@BaseMediaService, ComponentName(this@BaseMediaService, this@BaseMediaService::class.java))
|
||||
).buildAsync()
|
||||
MediaManager.continuousPlay(player.currentMediaItem, browserFuture)
|
||||
}
|
||||
}
|
||||
|
||||
if (player is ExoPlayer) {
|
||||
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
|
||||
if (MediaManager.justStarted.get()) {
|
||||
Log.d(TAG, "update shuffle order")
|
||||
MediaManager.justStarted.set(false)
|
||||
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
|
||||
shuffledList.shuffle()
|
||||
val index = shuffledList.indexOf(player.currentMediaItemIndex)
|
||||
// swap current media index to the first index
|
||||
if (index > -1 && shuffledList.isNotEmpty()) {
|
||||
val tmp = shuffledList[0]
|
||||
shuffledList[0] = shuffledList[index]
|
||||
shuffledList[index] = tmp
|
||||
}
|
||||
player.shuffleOrder =
|
||||
DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: Metadata) {
|
||||
// Handle streaming metadata (ICY, ID3) for radio / streaming content
|
||||
val currentItem = player.currentMediaItem ?: return
|
||||
val extras = currentItem.mediaMetadata.extras
|
||||
if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return
|
||||
|
||||
var artist: String? = null
|
||||
var title: String? = null
|
||||
|
||||
// Extract metadata from ICY/ID3/Vorbis
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val entry = metadata[i]) {
|
||||
is IcyInfo -> {
|
||||
entry.title?.let { icyTitle ->
|
||||
val parts = icyTitle.split(" - ", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
artist = parts[0].trim().ifEmpty { null }
|
||||
title = parts[1].trim().ifEmpty { null }
|
||||
} else {
|
||||
title = icyTitle.trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
is TextInformationFrame -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = entry.value
|
||||
when (entry.id) {
|
||||
"TPE1" -> if (!value.isNullOrBlank()) artist = value
|
||||
"TIT2" -> if (!value.isNullOrBlank()) title = value
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = entry.value
|
||||
when (entry.key) {
|
||||
"ARTIST" -> if (!value.isNullOrBlank()) artist = value
|
||||
"TITLE" -> if (!value.isNullOrBlank()) title = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
|
||||
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
|
||||
|
||||
lastRadioArtist = artist
|
||||
lastRadioTitle = title
|
||||
|
||||
// Stop HTTP header checks since we have embedded metadata
|
||||
stopRadioHeaderChecks()
|
||||
|
||||
val currentIndex = player.currentMediaItemIndex
|
||||
if (currentIndex == C.INDEX_UNSET) return
|
||||
|
||||
val metadataBuilder = currentItem.mediaMetadata.buildUpon()
|
||||
val newExtras = Bundle(extras ?: Bundle())
|
||||
|
||||
// Store individual values in extras for UI
|
||||
artist?.let { newExtras.putString("radioArtist", it) }
|
||||
title?.let { newExtras.putString("radioTitle", it) }
|
||||
|
||||
// Get station name (preserve if already set)
|
||||
val stationName = extras?.getString("stationName")
|
||||
?: currentItem.mediaMetadata.title?.toString()
|
||||
?: ""
|
||||
if (stationName.isNotBlank()) {
|
||||
newExtras.putString("stationName", stationName)
|
||||
}
|
||||
|
||||
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
|
||||
val formattedTitle = when {
|
||||
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
|
||||
!title.isNullOrBlank() -> title
|
||||
!artist.isNullOrBlank() -> artist
|
||||
else -> stationName
|
||||
}
|
||||
|
||||
metadataBuilder.setTitle(formattedTitle)
|
||||
if (stationName.isNotBlank()) {
|
||||
metadataBuilder.setArtist(stationName)
|
||||
}
|
||||
|
||||
(player as? ExoPlayer)?.let { exo ->
|
||||
exo.replaceMediaItem(currentIndex, currentItem.buildUpon()
|
||||
.setMediaMetadata(metadataBuilder.setExtras(newExtras).build())
|
||||
.build())
|
||||
updateWidget(exo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex)
|
||||
if (!isPlaying) {
|
||||
MediaManager.setPlayingPausedTimestamp(
|
||||
player.currentMediaItem,
|
||||
player.currentPosition
|
||||
)
|
||||
} else {
|
||||
MediaManager.scrobble(player.currentMediaItem, false)
|
||||
}
|
||||
if (isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
scheduleRadioHeaderChecks()
|
||||
} else {
|
||||
stopWidgetUpdates()
|
||||
stopRadioHeaderChecks()
|
||||
}
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(TAG, "onPlaybackStateChanged")
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
if (!player.hasNextMediaItem() &&
|
||||
playbackState == Player.STATE_ENDED &&
|
||||
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
||||
) {
|
||||
MediaManager.scrobble(player.currentMediaItem, true)
|
||||
MediaManager.saveChronology(player.currentMediaItem)
|
||||
}
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
Log.d(TAG, "onPositionDiscontinuity")
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
|
||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
||||
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.scrobble(oldPosition.mediaItem, true)
|
||||
MediaManager.saveChronology(oldPosition.mediaItem)
|
||||
}
|
||||
|
||||
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
||||
Preferences.setRepeatMode(repeatMode)
|
||||
}
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
Log.d(TAG, "onAudioSessionIdChanged")
|
||||
attachEqualizerIfPossible(audioSessionId)
|
||||
}
|
||||
})
|
||||
if (player.isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
|
||||
if (oldPlayer === newPlayer) return
|
||||
if (oldPlayer != null) {
|
||||
val currentQueue = getQueueFromPlayer(oldPlayer)
|
||||
val currentIndex = oldPlayer.currentMediaItemIndex
|
||||
val currentPosition = oldPlayer.currentPosition
|
||||
val isPlaying = oldPlayer.playWhenReady
|
||||
oldPlayer.stop()
|
||||
newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
|
||||
newPlayer.playWhenReady = isPlaying
|
||||
newPlayer.prepare()
|
||||
}
|
||||
mediaLibrarySession.player = newPlayer
|
||||
}
|
||||
|
||||
open fun releasePlayers() {
|
||||
exoplayer.release()
|
||||
}
|
||||
|
||||
fun getQueueFromPlayer(player: Player): List<MediaItem> {
|
||||
return (0..player.mediaItemCount - 1).map(player::getMediaItemAt)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaLibrarySession.player
|
||||
|
||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
playerInitHook()
|
||||
initializeEqualizerManager()
|
||||
initializeNetworkListener()
|
||||
restorePlayerFromQueue(mediaLibrarySession.player)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseNetworkCallback()
|
||||
equalizerManager.release()
|
||||
stopWidgetUpdates()
|
||||
stopRadioHeaderChecks()
|
||||
radioHeaderCheckExecutor.shutdown()
|
||||
releasePlayers()
|
||||
mediaLibrarySession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// Check if the intent is for our custom equalizer binder
|
||||
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
||||
return binder
|
||||
}
|
||||
// Otherwise, handle it as a normal MediaLibraryService connection
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun initializeExoPlayer() {
|
||||
exoplayer = ExoPlayer.Builder(this)
|
||||
.setRenderersFactory(getRenderersFactory())
|
||||
.setMediaSourceFactory(getMediaSourceFactory())
|
||||
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setLoadControl(initializeLoadControl())
|
||||
.build()
|
||||
|
||||
exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
||||
exoplayer.repeatMode = Preferences.getRepeatMode()
|
||||
}
|
||||
|
||||
private fun initializeEqualizerManager() {
|
||||
equalizerManager = EqualizerManager()
|
||||
val audioSessionId = exoplayer.audioSessionId
|
||||
attachEqualizerIfPossible(audioSessionId)
|
||||
}
|
||||
|
||||
private fun initializeMediaLibrarySession(player: Player) {
|
||||
Log.d(TAG, "initializeMediaLibrarySession")
|
||||
val sessionActivityPendingIntent =
|
||||
TaskStackBuilder.create(this).run {
|
||||
addNextIntent(Intent(baseContext, MainActivity::class.java))
|
||||
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
mediaLibrarySession =
|
||||
MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback())
|
||||
.setSessionActivity(sessionActivityPendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun initializeNetworkListener() {
|
||||
networkCallback = CustomNetworkCallback()
|
||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(
|
||||
networkCallback
|
||||
)
|
||||
updateMediaItems(mediaLibrarySession.player)
|
||||
}
|
||||
|
||||
private fun initializeLoadControl(): DefaultLoadControl {
|
||||
return DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun releaseNetworkCallback() {
|
||||
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
private fun updateWidget(player: Player) {
|
||||
val mi = player.currentMediaItem
|
||||
val title = mi?.mediaMetadata?.title?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("title")
|
||||
val artist = mi?.mediaMetadata?.artist?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||
val extras = mi?.mediaMetadata?.extras
|
||||
val coverId = extras?.getString("coverArtId")
|
||||
val songLink = extras?.getString("assetLinkSong")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
||||
val albumLink = extras?.getString("assetLinkAlbum")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
||||
val artistLink = extras?.getString("assetLinkArtist")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
WidgetUpdateManager.updateFromState(
|
||||
this,
|
||||
title ?: "",
|
||||
artist ?: "",
|
||||
album ?: "",
|
||||
coverId,
|
||||
player.isPlaying,
|
||||
player.shuffleModeEnabled,
|
||||
player.repeatMode,
|
||||
position,
|
||||
duration,
|
||||
songLink,
|
||||
albumLink,
|
||||
artistLink
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleWidgetUpdates() {
|
||||
if (widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
||||
widgetUpdateScheduled = true
|
||||
}
|
||||
|
||||
private fun stopWidgetUpdates() {
|
||||
if (!widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
||||
widgetUpdateScheduled = false
|
||||
}
|
||||
|
||||
private fun scheduleRadioHeaderChecks() {
|
||||
val player = mediaLibrarySession.player
|
||||
val currentItem = player.currentMediaItem ?: return
|
||||
val mediaType = currentItem.mediaMetadata.extras?.getString("type")
|
||||
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
|
||||
|
||||
if (radioHeaderCheckScheduled) return
|
||||
|
||||
// Check immediately, then periodically
|
||||
checkRadioHttpHeaders()
|
||||
radioHeaderCheckFuture = radioHeaderCheckExecutor.scheduleWithFixedDelay(
|
||||
radioHeaderCheckRunnable,
|
||||
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
|
||||
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
radioHeaderCheckScheduled = true
|
||||
}
|
||||
|
||||
private fun stopRadioHeaderChecks() {
|
||||
if (!radioHeaderCheckScheduled) return
|
||||
radioHeaderCheckFuture?.cancel(false)
|
||||
radioHeaderCheckFuture = null
|
||||
radioHeaderCheckScheduled = false
|
||||
}
|
||||
|
||||
private fun checkRadioHttpHeaders() {
|
||||
val player = mediaLibrarySession.player
|
||||
val currentItem = player.currentMediaItem ?: return
|
||||
val extras = currentItem.mediaMetadata.extras
|
||||
val mediaType = extras?.getString("type")
|
||||
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
|
||||
|
||||
// Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback
|
||||
val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() ||
|
||||
!currentItem.mediaMetadata.title.isNullOrBlank() ||
|
||||
(extras != null && !extras.getString("radioArtist").isNullOrBlank()) ||
|
||||
(extras != null && !extras.getString("radioTitle").isNullOrBlank())
|
||||
if (hasEmbeddedMetadata) return
|
||||
|
||||
val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString()
|
||||
if (streamUrl.isNullOrBlank()) return
|
||||
|
||||
try {
|
||||
val url = URL(streamUrl)
|
||||
val connection = url.openConnection() as? HttpURLConnection ?: return
|
||||
|
||||
// Only try HEAD request (lightweight) - skip GET fallback as it's unreliable
|
||||
connection.requestMethod = "HEAD"
|
||||
connection.setRequestProperty("Icy-MetaData", "1")
|
||||
connection.setRequestProperty("User-Agent", "Tempus/1.0")
|
||||
connection.connectTimeout = 3000 // Reduced timeout
|
||||
connection.readTimeout = 3000
|
||||
|
||||
connection.connect()
|
||||
|
||||
if (connection.responseCode >= 400) {
|
||||
connection.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// Check for metadata in HTTP headers
|
||||
val streamTitle = connection.getHeaderField("icy-name")
|
||||
?: connection.getHeaderField("StreamTitle")
|
||||
?: connection.getHeaderField("stream-title")
|
||||
|
||||
connection.disconnect()
|
||||
|
||||
if (!streamTitle.isNullOrBlank()) {
|
||||
processStreamTitle(streamTitle, player)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Silently fail - this is a fallback mechanism, ICY metadata is primary
|
||||
}
|
||||
}
|
||||
|
||||
private fun processStreamTitle(streamTitle: String, player: Player) {
|
||||
// Parse "Artist - Title" format
|
||||
val parts = streamTitle.split(" - ", limit = 2)
|
||||
val artist = if (parts.size == 2) parts[0].trim().ifEmpty { null } else null
|
||||
val title = if (parts.size == 2) parts[1].trim().ifEmpty { null } else streamTitle.trim().ifEmpty { null }
|
||||
|
||||
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
|
||||
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
|
||||
|
||||
lastRadioArtist = artist
|
||||
lastRadioTitle = title
|
||||
|
||||
// Update on main thread
|
||||
widgetUpdateHandler.post {
|
||||
val currentItemNow = player.currentMediaItem ?: return@post
|
||||
val currentIndex = player.currentMediaItemIndex
|
||||
if (currentIndex == C.INDEX_UNSET) return@post
|
||||
|
||||
val currentExtras = currentItemNow.mediaMetadata.extras
|
||||
if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post
|
||||
|
||||
// Double-check we still don't have embedded metadata (might have arrived since check)
|
||||
val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() ||
|
||||
!currentItemNow.mediaMetadata.title.isNullOrBlank() ||
|
||||
(currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) ||
|
||||
(currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank())
|
||||
if (hasEmbeddedMetadata) return@post
|
||||
|
||||
val metadataBuilder = currentItemNow.mediaMetadata.buildUpon()
|
||||
val newExtras = Bundle(currentExtras ?: Bundle())
|
||||
|
||||
// Store individual values in extras for UI
|
||||
artist?.let { newExtras.putString("radioArtist", it) }
|
||||
title?.let { newExtras.putString("radioTitle", it) }
|
||||
|
||||
// Get station name (preserve if already set)
|
||||
val stationName = currentExtras?.getString("stationName")
|
||||
?: currentItemNow.mediaMetadata.title?.toString()
|
||||
?: ""
|
||||
if (stationName.isNotBlank()) {
|
||||
newExtras.putString("stationName", stationName)
|
||||
}
|
||||
|
||||
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
|
||||
val formattedTitle = when {
|
||||
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
|
||||
!title.isNullOrBlank() -> title
|
||||
!artist.isNullOrBlank() -> artist
|
||||
else -> stationName
|
||||
}
|
||||
|
||||
metadataBuilder.setTitle(formattedTitle)
|
||||
if (stationName.isNotBlank()) {
|
||||
metadataBuilder.setArtist(stationName)
|
||||
}
|
||||
metadataBuilder.setExtras(newExtras)
|
||||
|
||||
(player as? ExoPlayer)?.let { exo ->
|
||||
exo.replaceMediaItem(currentIndex, currentItemNow.buildUpon()
|
||||
.setMediaMetadata(metadataBuilder.build())
|
||||
.build())
|
||||
updateWidget(exo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
|
||||
if (audioSessionId == 0 || audioSessionId == -1) return false
|
||||
val attached = equalizerManager.attachToSession(audioSessionId)
|
||||
if (attached) {
|
||||
val enabled = Preferences.isEqualizerEnabled()
|
||||
equalizerManager.setEnabled(enabled)
|
||||
val bands = equalizerManager.getNumberOfBands()
|
||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||
for (i in 0 until bands) {
|
||||
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
||||
}
|
||||
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
|
||||
}
|
||||
return attached
|
||||
}
|
||||
|
||||
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
||||
|
||||
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
|
||||
|
||||
@UnstableApi
|
||||
private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
||||
private val shuffleCommands: List<CommandButton>
|
||||
private val repeatCommands: List<CommandButton>
|
||||
|
||||
constructor(ctx: Context) {
|
||||
shuffleCommands = listOf(
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON,
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
|
||||
)
|
||||
.map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||
repeatCommands = listOf(
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
|
||||
)
|
||||
.map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||
}
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
(shuffleCommands + repeatCommands).forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||
}
|
||||
|
||||
val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(availableSessionCommands.build())
|
||||
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
|
||||
.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||
.build()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
Log.d(TAG, "onCustomCommand")
|
||||
when (customCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
|
||||
val nextMode = when (session.player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
session.player.repeatMode = nextMode
|
||||
}
|
||||
}
|
||||
|
||||
session.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
Log.d(TAG, "onAddMediaItems")
|
||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
val mediaMetadata = mediaItem.mediaMetadata
|
||||
val newMetadata = mediaMetadata.buildUpon()
|
||||
.setArtist(
|
||||
if (mediaMetadata.artist != null) mediaMetadata.artist
|
||||
else mediaMetadata.extras?.getString("uri") ?: ""
|
||||
)
|
||||
.build()
|
||||
|
||||
mediaItem.buildUpon()
|
||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||
.setMediaMetadata(newMetadata)
|
||||
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||
.build()
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems)
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun getShuffleCommandButton(
|
||||
sessionCommand: SessionCommand,
|
||||
ctx: Context
|
||||
): CommandButton {
|
||||
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
|
||||
return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setDisplayName(
|
||||
ctx.getString(
|
||||
if (isOn) R.string.exo_controls_shuffle_on_description
|
||||
else R.string.exo_controls_shuffle_off_description
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun getRepeatCommandButton(
|
||||
sessionCommand: SessionCommand,
|
||||
ctx: Context
|
||||
): CommandButton {
|
||||
val icon = when (sessionCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL
|
||||
else -> CommandButton.ICON_REPEAT_OFF
|
||||
}
|
||||
val description = when (sessionCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
|
||||
else -> R.string.exo_controls_repeat_off_description
|
||||
}
|
||||
return CommandButton.Builder(icon)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setDisplayName(ctx.getString(description))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
|
||||
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
|
||||
val repeat = when (player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> repeatCommands[1]
|
||||
Player.REPEAT_MODE_ALL -> repeatCommands[2]
|
||||
else -> repeatCommands[0]
|
||||
}
|
||||
return ImmutableList.of(shuffle, repeat)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
|
||||
var wasWifi = false
|
||||
|
||||
init {
|
||||
val manager = getSystemService(ConnectivityManager::class.java)
|
||||
val network = manager.activeNetwork
|
||||
val capabilities = manager.getNetworkCapabilities(network)
|
||||
if (capabilities != null)
|
||||
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
if (isWifi != wasWifi) {
|
||||
wasWifi = isWifi
|
||||
widgetUpdateHandler.post {
|
||||
updateMediaItems(mediaLibrarySession.player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getEqualizerManager(): EqualizerManager {
|
||||
return equalizerManager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
||||
private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.media.audiofx.Equalizer
|
||||
|
||||
class EqualizerManager {
|
||||
|
||||
private var equalizer: Equalizer? = null
|
||||
|
||||
fun attachToSession(audioSessionId: Int): Boolean {
|
||||
release()
|
||||
if (audioSessionId != 0 && audioSessionId != -1) {
|
||||
try {
|
||||
equalizer = Equalizer(0, audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
// Some devices may not support Equalizer or audio session may be invalid
|
||||
equalizer = null
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setBandLevel(band: Short, level: Short) {
|
||||
equalizer?.setBandLevel(band, level)
|
||||
}
|
||||
|
||||
fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0
|
||||
|
||||
fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange
|
||||
|
||||
fun getCenterFreq(band: Short): Int? =
|
||||
equalizer?.getCenterFreq(band)?.div(1000)
|
||||
|
||||
fun getBandLevel(band: Short): Short? =
|
||||
equalizer?.getBandLevel(band)
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
equalizer?.enabled = enabled
|
||||
}
|
||||
|
||||
fun release() {
|
||||
equalizer?.release()
|
||||
equalizer = null
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.cappielloantonio.tempo.service;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Timeline;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.session.SessionToken;
|
||||
@@ -19,16 +26,88 @@ import com.cappielloantonio.tempo.repository.SongRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
|
||||
import com.cappielloantonio.tempo.util.Constants.SeedType;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class MediaManager {
|
||||
private static final String TAG = "MediaManager";
|
||||
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
|
||||
public static AtomicBoolean justStarted = new AtomicBoolean(false);
|
||||
|
||||
private static final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
public static void registerPlaybackObserver(
|
||||
ListenableFuture<MediaBrowser> browserFuture,
|
||||
PlaybackViewModel playbackViewModel
|
||||
) {
|
||||
if (browserFuture == null) return;
|
||||
|
||||
Futures.addCallback(browserFuture, new FutureCallback<MediaBrowser>() {
|
||||
@Override
|
||||
public void onSuccess(MediaBrowser browser) {
|
||||
MediaBrowser current = attachedBrowserRef.get();
|
||||
if (current != browser) {
|
||||
browser.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onEvents(@NonNull Player player, @NonNull Player.Events events) {
|
||||
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)
|
||||
|| events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
|
||||
|| events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
|
||||
String mediaId = player.getCurrentMediaItem() != null
|
||||
? player.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
|
||||
boolean playing = player.getPlaybackState() == Player.STATE_READY
|
||||
&& player.getPlayWhenReady();
|
||||
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
String mediaId = browser.getCurrentMediaItem() != null
|
||||
? browser.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
|
||||
attachedBrowserRef = new WeakReference<>(browser);
|
||||
} else {
|
||||
String mediaId = browser.getCurrentMediaItem() != null
|
||||
? browser.getCurrentMediaItem().mediaId
|
||||
: null;
|
||||
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
|
||||
playbackViewModel.update(mediaId, playing);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Log.e(TAG, "Failed to get MediaBrowser instance", t);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
public static void onBrowserReleased(@Nullable MediaBrowser released) {
|
||||
MediaBrowser attached = attachedBrowserRef.get();
|
||||
if (attached == released) {
|
||||
attachedBrowserRef.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static void reset(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
@@ -102,20 +181,46 @@ public class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().seekTo(startIndex, 0);
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
enqueueDatabase(media, true, 0);
|
||||
final MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
|
||||
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
justStarted.set(true);
|
||||
browser.setMediaItems(items, startIndex, 0);
|
||||
browser.prepare();
|
||||
|
||||
Player.Listener timelineListener = new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
|
||||
int itemCount = browser.getMediaItemCount();
|
||||
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
||||
browser.seekTo(startIndex, 0);
|
||||
browser.play();
|
||||
browser.removeListener(this);
|
||||
} else {
|
||||
Log.d(TAG, "Cannot start playback: itemCount=" + itemCount + ", startIndex=" + startIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
browser.addListener(timelineListener);
|
||||
});
|
||||
|
||||
backgroundExecutor.execute(() -> {
|
||||
Log.d(TAG, "Background: enqueuing to database");
|
||||
enqueueDatabase(media, true, 0);
|
||||
});
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
Log.e(TAG, "Error in startQueue: " + e.getMessage(), e);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
@@ -126,10 +231,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(media));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapMediaItem(media));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
enqueueDatabase(media, true, 0);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
@@ -139,15 +245,35 @@ public class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static void playDownloadedMediaItem(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, MediaItem mediaItem) {
|
||||
if (mediaBrowserListenableFuture != null && mediaItem != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
mediaBrowser.setMediaItem(mediaItem);
|
||||
mediaBrowser.prepare();
|
||||
mediaBrowser.play();
|
||||
clearDatabase();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
}
|
||||
|
||||
public static void startRadio(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
@@ -161,10 +287,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(podcastEpisode));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapMediaItem(podcastEpisode));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
@@ -178,9 +305,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex());
|
||||
mediaBrowserListenableFuture.get().addMediaItems(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItems(media));
|
||||
Log.e(TAG, "enqueue");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
|
||||
browser.addMediaItems(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItems(media));
|
||||
} else {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
|
||||
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media));
|
||||
@@ -198,9 +327,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex());
|
||||
mediaBrowserListenableFuture.get().addMediaItem(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItem(media));
|
||||
Log.e(TAG, "enqueue");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
|
||||
browser.addMediaItem(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItem(media));
|
||||
} else {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
|
||||
mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media));
|
||||
@@ -218,8 +349,10 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().removeMediaItems(startIndex, endIndex + 1);
|
||||
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1));
|
||||
Log.e(TAG, "shuffle");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
browser.removeMediaItems(startIndex, endIndex + 1);
|
||||
browser.addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1));
|
||||
swapDatabase(media);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
@@ -234,6 +367,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "swap");
|
||||
mediaBrowserListenableFuture.get().moveMediaItem(from, to);
|
||||
swapDatabase(media);
|
||||
}
|
||||
@@ -249,6 +383,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "remove");
|
||||
if (mediaBrowserListenableFuture.get().getMediaItemCount() > 1 && mediaBrowserListenableFuture.get().getCurrentMediaItemIndex() != toRemove) {
|
||||
mediaBrowserListenableFuture.get().removeMediaItem(toRemove);
|
||||
removeDatabase(media, toRemove);
|
||||
@@ -268,6 +403,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "remove range");
|
||||
mediaBrowserListenableFuture.get().removeMediaItems(fromItem, toItem);
|
||||
removeRangeDatabase(media, fromItem, toItem);
|
||||
}
|
||||
@@ -308,27 +444,33 @@ public class MediaManager {
|
||||
}
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public static void continuousPlay(MediaItem mediaItem) {
|
||||
if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) {
|
||||
Preferences.setLastInstantMix();
|
||||
|
||||
LiveData<List<Child>> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, 10);
|
||||
instantMix.observeForever(new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> media) {
|
||||
if (media != null) {
|
||||
ListenableFuture<MediaBrowser> mediaBrowserListenableFuture = new MediaBrowser.Builder(
|
||||
App.getContext(),
|
||||
new SessionToken(App.getContext(), new ComponentName(App.getContext(), MediaService.class))
|
||||
).buildAsync();
|
||||
|
||||
enqueue(mediaBrowserListenableFuture, media, true);
|
||||
}
|
||||
|
||||
instantMix.removeObserver(this);
|
||||
}
|
||||
});
|
||||
public static void continuousPlay(MediaItem mediaItem,
|
||||
ListenableFuture<MediaBrowser> existingBrowserFuture) {
|
||||
if (mediaItem == null
|
||||
|| !Preferences.isContinuousPlayEnabled()
|
||||
|| !Preferences.isInstantMixUsable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Preferences.setLastInstantMix();
|
||||
|
||||
LiveData<List<Child>> instantMix =
|
||||
getSongRepository().getContinuousMix(mediaItem.mediaId, 25);
|
||||
|
||||
instantMix.observeForever(new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> media) {
|
||||
if (media == null || media.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingBrowserFuture != null) {
|
||||
Log.d(TAG, "Continuous play: adding " + media.size() + " tracks");
|
||||
enqueue(existingBrowserFuture, media, true);
|
||||
}
|
||||
instantMix.removeObserver(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void saveChronology(MediaItem mediaItem) {
|
||||
|
||||
@@ -2,22 +2,29 @@ package com.cappielloantonio.tempo.subsonic
|
||||
|
||||
import com.cappielloantonio.tempo.App
|
||||
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
|
||||
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
|
||||
import com.cappielloantonio.tempo.util.ClientCertManager
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RetrofitClient(subsonic: Subsonic) {
|
||||
var retrofit: Retrofit
|
||||
val retrofit: Retrofit
|
||||
|
||||
init {
|
||||
val gson = GsonBuilder()
|
||||
.registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter())
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
retrofit = Retrofit.Builder()
|
||||
.baseUrl(subsonic.url)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create()))
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.client(getOkHttpClient())
|
||||
.build()
|
||||
}
|
||||
@@ -44,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
|
||||
.addInterceptor(cacheUtil.offlineInterceptor)
|
||||
// .addNetworkInterceptor(cacheUtil.onlineInterceptor)
|
||||
.cache(getCache())
|
||||
.setupSsl()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -57,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) {
|
||||
val cacheSize = 10 * 1024 * 1024
|
||||
return Cache(App.getContext().cacheDir, cacheSize.toLong())
|
||||
}
|
||||
|
||||
private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder {
|
||||
ClientCertManager.sslSocketFactory?.let { sslSocketFactory ->
|
||||
sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import java.util.UUID;
|
||||
public class SubsonicPreferences {
|
||||
private String serverUrl;
|
||||
private String username;
|
||||
private String clientName = "Tempo";
|
||||
private String clientName = "Tempus";
|
||||
private SubsonicAuthentication authentication;
|
||||
|
||||
public String getServerUrl() {
|
||||
|
||||
@@ -34,6 +34,11 @@ public class AlbumSongListClient {
|
||||
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear);
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getRandomSongs(int size, Integer fromYear, Integer toYear, String genre) {
|
||||
Log.d(TAG, "getRandomSongs()");
|
||||
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear, genre);
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getSongsByGenre(String genre, int count, int offset) {
|
||||
Log.d(TAG, "getSongsByGenre()");
|
||||
return albumSongListService.getSongsByGenre(subsonic.getParams(), genre, count, offset);
|
||||
|
||||
@@ -19,6 +19,9 @@ public interface AlbumSongListService {
|
||||
@GET("getRandomSongs")
|
||||
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear);
|
||||
|
||||
@GET("getRandomSongs")
|
||||
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear, @Query("genre") String genre);
|
||||
|
||||
@GET("getSongsByGenre")
|
||||
Call<ApiResponse> getSongsByGenre(@QueryMap Map<String, String> params, @Query("genre") String genre, @Query("count") int count, @Query("offset") int offset);
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ public class SearchingClient {
|
||||
return searchingService.search2(subsonic.getParams(), query, songCount, albumCount, artistCount);
|
||||
}
|
||||
|
||||
public Call<ApiResponse> search3(String query, int songCount, int albumCount, int artistCount) {
|
||||
public Call<ApiResponse> search3(String query, int songCount, int songOffset, int albumCount, int albumOffset, int artistCount, int artistOffset) {
|
||||
Log.d(TAG, "search3()");
|
||||
return searchingService.search3(subsonic.getParams(), query, songCount, albumCount, artistCount);
|
||||
return searchingService.search3(subsonic.getParams(), query, songCount, songOffset, albumCount, albumOffset, artistCount, artistOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@ public interface SearchingService {
|
||||
Call<ApiResponse> search2(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("albumCount") int albumCount, @Query("artistCount") int artistCount);
|
||||
|
||||
@GET("search3")
|
||||
Call<ApiResponse> search3(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("albumCount") int albumCount, @Query("artistCount") int artistCount);
|
||||
Call<ApiResponse> search3(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("songOffset") int songOffset, @Query("albumCount") int albumCount, @Query("albumOffset") int albumOffset, @Query("artistCount") int artistCount, @Query("artistOffset") int artistOffset);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.util.Log;
|
||||
import com.cappielloantonio.tempo.subsonic.RetrofitClient;
|
||||
import com.cappielloantonio.tempo.subsonic.Subsonic;
|
||||
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
|
||||
@@ -21,7 +24,17 @@ public class SystemClient {
|
||||
|
||||
public Call<ApiResponse> ping() {
|
||||
Log.d(TAG, "ping()");
|
||||
return systemService.ping(subsonic.getParams());
|
||||
int timeoutSeconds = Preferences.getNetworkPingTimeout();
|
||||
Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams());
|
||||
if (Preferences.isInUseServerAddressLocal()) {
|
||||
pingCall.timeout()
|
||||
.timeout(timeoutSeconds, TimeUnit.SECONDS);
|
||||
} else {
|
||||
int finalTimeout = Math.min(timeoutSeconds * 2, 10);
|
||||
pingCall.timeout()
|
||||
.timeout(finalTimeout, TimeUnit.SECONDS);
|
||||
}
|
||||
return pingCall;
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getLicense() {
|
||||
|
||||
@@ -4,38 +4,36 @@ import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class AlbumID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var artist: String? = null
|
||||
var artistId: String? = null
|
||||
open class AlbumID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var artist: String? = null,
|
||||
var artistId: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var songCount: Int? = 0
|
||||
var duration: Int? = 0
|
||||
var playCount: Long? = 0
|
||||
var created: Date? = null
|
||||
var starred: Date? = null
|
||||
var year: Int = 0
|
||||
var genre: String? = null
|
||||
var played: Date? = Date(0)
|
||||
var userRating: Int? = 0
|
||||
var recordLabels: List<RecordLabel>? = null
|
||||
var musicBrainzId: String? = null
|
||||
var genres: List<ItemGenre>? = null
|
||||
var artists: List<ArtistID3>? = null
|
||||
var displayArtist: String? = null
|
||||
var releaseTypes: List<String>? = null
|
||||
var moods: List<String>? = null
|
||||
var sortName: String? = null
|
||||
var originalReleaseDate: ItemDate? = null
|
||||
var releaseDate: ItemDate? = null
|
||||
var isCompilation: Boolean? = null
|
||||
var discTitles: List<DiscTitle>? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var songCount: Int? = 0,
|
||||
var duration: Int? = 0,
|
||||
var playCount: Long? = 0,
|
||||
var created: Date? = null,
|
||||
var starred: Date? = null,
|
||||
var year: Int = 0,
|
||||
var genre: String? = null,
|
||||
var played: Date? = Date(0),
|
||||
var userRating: Int? = 0,
|
||||
var recordLabels: List<RecordLabel>? = null,
|
||||
var musicBrainzId: String? = null,
|
||||
var genres: List<ItemGenre>? = null,
|
||||
var artists: List<ArtistID3>? = null,
|
||||
var displayArtist: String? = null,
|
||||
var releaseTypes: List<String>? = null,
|
||||
var moods: List<String>? = null,
|
||||
var sortName: String? = null,
|
||||
var originalReleaseDate: ItemDate? = null,
|
||||
var releaseDate: ItemDate? = null,
|
||||
var isCompilation: Boolean? = null,
|
||||
var discTitles: List<DiscTitle>? = null,
|
||||
) : Parcelable
|
||||
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class AlbumWithSongsID3 : AlbumID3(), Parcelable {
|
||||
class AlbumWithSongsID3(
|
||||
@SerializedName("song")
|
||||
var songs: List<Child>? = null
|
||||
}
|
||||
var songs: List<Child>? = null,
|
||||
) : AlbumID3(), Parcelable
|
||||
@@ -7,10 +7,10 @@ import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Artist : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
}
|
||||
class Artist(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
) : Parcelable
|
||||
@@ -4,15 +4,15 @@ import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ArtistID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
open class ArtistID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var albumCount = 0
|
||||
var starred: Date? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var albumCount: Int = 0,
|
||||
var starred: Date? = null,
|
||||
) : Parcelable
|
||||
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
|
||||
class ArtistWithAlbumsID3(
|
||||
@SerializedName("album")
|
||||
var albums: List<AlbumID3>? = null
|
||||
}
|
||||
var albums: List<AlbumID3>? = null,
|
||||
) : ArtistID3(), Parcelable
|
||||
@@ -8,15 +8,15 @@ import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Directory : Parcelable {
|
||||
class Directory(
|
||||
@SerializedName("child")
|
||||
var children: List<Child>? = null
|
||||
var id: String? = null
|
||||
var children: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
var playCount: Long? = null
|
||||
}
|
||||
var parentId: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
var playCount: Long? = null,
|
||||
) : Parcelable
|
||||
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class DiscTitle : Parcelable {
|
||||
var disc: Int? = null
|
||||
var title: String? = null
|
||||
}
|
||||
open class DiscTitle(
|
||||
var disc: Int? = null,
|
||||
var title: String? = null,
|
||||
) : Parcelable
|
||||
@@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Genre : Parcelable {
|
||||
class Genre(
|
||||
@SerializedName("value")
|
||||
var genre: String? = null
|
||||
var songCount = 0
|
||||
var albumCount = 0
|
||||
}
|
||||
var genre: String? = null,
|
||||
var songCount: Int = 0,
|
||||
var albumCount: Int = 0,
|
||||
) : Parcelable
|
||||
@@ -3,12 +3,14 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class InternetRadioStation : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var streamUrl: String? = null
|
||||
var homePageUrl: String? = null
|
||||
}
|
||||
class InternetRadioStation(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var streamUrl: String? = null,
|
||||
@SerializedName("homePageUrl", alternate = ["homepageUrl"])
|
||||
var homePageUrl: String? = null,
|
||||
) : Parcelable
|
||||
@@ -9,11 +9,11 @@ import java.util.Locale
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemDate : Parcelable {
|
||||
var year: Int? = null
|
||||
var month: Int? = null
|
||||
var day: Int? = null
|
||||
|
||||
open class ItemDate(
|
||||
var year: Int? = null,
|
||||
var month: Int? = null,
|
||||
var day: Int? = null,
|
||||
) : Parcelable {
|
||||
fun getFormattedDate(): String? {
|
||||
if (year == null && month == null && day == null) return null
|
||||
|
||||
@@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
|
||||
SimpleDateFormat("yyyy", Locale.getDefault())
|
||||
} else if (day == null) {
|
||||
SimpleDateFormat("MMMM yyyy", Locale.getDefault())
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemGenre : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class ItemGenre(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class MusicFolder : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
}
|
||||
class MusicFolder(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
|
||||
@Parcelize
|
||||
class NowPlayingEntry(
|
||||
@SerializedName("_id")
|
||||
override val id: String
|
||||
) : Child(id) {
|
||||
var username: String? = null
|
||||
var minutesAgo = 0
|
||||
var playerId = 0
|
||||
var playerName: String? = null
|
||||
}
|
||||
override val id: String,
|
||||
var username: String? = null,
|
||||
var minutesAgo: Int = 0,
|
||||
var playerId: Int = 0,
|
||||
var playerName: String? = null,
|
||||
) : Child(id)
|
||||
@@ -7,8 +7,9 @@ import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
@@ -16,27 +17,57 @@ import java.util.*
|
||||
open class Playlist(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "id")
|
||||
open var id: String
|
||||
) : Parcelable {
|
||||
open var id: String,
|
||||
@ColumnInfo(name = "name")
|
||||
var name: String? = null
|
||||
var name: String? = null,
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0,
|
||||
@SerializedName("coverArt")
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null,
|
||||
) : Parcelable {
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var comment: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var owner: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
@SerializedName("public")
|
||||
var isUniversal: Boolean? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var songCount: Int = 0
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var created: Date? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var changed: Date? = null
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var allowedUsers: List<String>? = null
|
||||
@Ignore
|
||||
constructor(
|
||||
id: String,
|
||||
name: String?,
|
||||
comment: String?,
|
||||
owner: String?,
|
||||
isUniversal: Boolean?,
|
||||
songCount: Int,
|
||||
duration: Long,
|
||||
created: Date?,
|
||||
changed: Date?,
|
||||
coverArtId: String?,
|
||||
allowedUsers: List<String>?,
|
||||
) : this(id, name, duration, coverArtId) {
|
||||
this.comment = comment
|
||||
this.owner = owner
|
||||
this.isUniversal = isUniversal
|
||||
this.songCount = songCount
|
||||
this.created = created
|
||||
this.changed = changed
|
||||
this.allowedUsers = allowedUsers
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
|
||||
@Parcelize
|
||||
class PlaylistWithSongs(
|
||||
@SerializedName("_id")
|
||||
override var id: String
|
||||
) : Playlist(id), Parcelable {
|
||||
override var id: String,
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
) : Playlist(id), Parcelable
|
||||
@@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
|
||||
@Keep
|
||||
class Playlists(
|
||||
@SerializedName("playlist")
|
||||
var playlists: List<Playlist>? = null
|
||||
var playlists: List<Playlist>? = null,
|
||||
)
|
||||
@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastChannel : Parcelable {
|
||||
class PodcastChannel(
|
||||
@SerializedName("episode")
|
||||
var episodes: List<PodcastEpisode>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var episodes: List<PodcastEpisode>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var title: String? = null,
|
||||
var description: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var originalImageUrl: String? = null
|
||||
var status: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var originalImageUrl: String? = null,
|
||||
var status: String? = null,
|
||||
var errorMessage: String? = null,
|
||||
) : Parcelable
|
||||
@@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastEpisode : Parcelable {
|
||||
var id: String? = null
|
||||
class PodcastEpisode(
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var isDir = false
|
||||
var title: String? = null
|
||||
var album: String? = null
|
||||
var artist: String? = null
|
||||
var year: Int? = null
|
||||
var genre: String? = null
|
||||
var parentId: String? = null,
|
||||
var isDir: Boolean = false,
|
||||
var title: String? = null,
|
||||
var album: String? = null,
|
||||
var artist: String? = null,
|
||||
var year: Int? = null,
|
||||
var genre: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var size: Long? = null
|
||||
var contentType: String? = null
|
||||
var suffix: String? = null
|
||||
var duration: Int? = null
|
||||
var coverArtId: String? = null,
|
||||
var size: Long? = null,
|
||||
var contentType: String? = null,
|
||||
var suffix: String? = null,
|
||||
var duration: Int? = null,
|
||||
@SerializedName("bitRate")
|
||||
var bitrate: Int? = null
|
||||
var path: String? = null
|
||||
var isVideo: Boolean = false
|
||||
var created: Date? = null
|
||||
var artistId: String? = null
|
||||
var type: String? = null
|
||||
var streamId: String? = null
|
||||
var channelId: String? = null
|
||||
var description: String? = null
|
||||
var status: String? = null
|
||||
var publishDate: Date? = null
|
||||
}
|
||||
var bitrate: Int? = null,
|
||||
var path: String? = null,
|
||||
var isVideo: Boolean = false,
|
||||
var created: Date? = null,
|
||||
var artistId: String? = null,
|
||||
var type: String? = null,
|
||||
var streamId: String? = null,
|
||||
var channelId: String? = null,
|
||||
var description: String? = null,
|
||||
var status: String? = null,
|
||||
var publishDate: Date? = null,
|
||||
) : Parcelable
|
||||
@@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class RecordLabel : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class RecordLabel(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Share : Parcelable {
|
||||
data class Share(
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var description: String? = null
|
||||
var username: String? = null
|
||||
var created: Date? = null
|
||||
var expires: Date? = null
|
||||
var lastVisited: Date? = null
|
||||
var visitCount = 0
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var description: String? = null,
|
||||
var username: String? = null,
|
||||
var created: Date? = null,
|
||||
var expires: Date? = null,
|
||||
var lastVisited: Date? = null,
|
||||
var visitCount: Int = 0
|
||||
) : Parcelable
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.cappielloantonio.tempo.subsonic.models
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
@Keep
|
||||
class SimilarSongs {
|
||||
@SerializedName("song")
|
||||
var songs: List<Child>? = null
|
||||
}
|
||||
@@ -38,21 +38,37 @@ public class CacheUtil {
|
||||
return chain.proceed(request);
|
||||
};
|
||||
|
||||
|
||||
private boolean isConnected() {
|
||||
ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
|
||||
if (connectivityManager != null) {
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
|
||||
if (network != null) {
|
||||
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
||||
|
||||
if (capabilities != null) {
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||
}
|
||||
}
|
||||
if (connectivityManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
if (network == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
||||
if (capabilities == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||
if (!hasInternet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH);
|
||||
if (!hasAppropriateTransport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cappielloantonio.tempo.subsonic.utils
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import java.lang.reflect.Type
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
// This adapter handles Date objects, returning null if the JSON string is empty or unparsable.
|
||||
class EmptyDateTypeAdapter : JsonDeserializer<Date> {
|
||||
|
||||
// Define the date formats expected from the Subsonic server.
|
||||
private val dateFormats: List<SimpleDateFormat> = listOf(
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
)
|
||||
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? {
|
||||
val jsonString = json.asString.trim()
|
||||
|
||||
if (jsonString.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (format in dateFormats) {
|
||||
try {
|
||||
return format.parse(jsonString)
|
||||
} catch (e: ParseException) {
|
||||
// Ignore and try the next format
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
package com.cappielloantonio.tempo.ui.activity;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.util.Log;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.navigation.ui.NavigationUI;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.BuildConfig;
|
||||
@@ -25,18 +31,25 @@ import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.broadcast.receiver.ConnectivityStatusBroadcastReceiver;
|
||||
import com.cappielloantonio.tempo.databinding.ActivityMainBinding;
|
||||
import com.cappielloantonio.tempo.github.utils.UpdateUtil;
|
||||
import com.cappielloantonio.tempo.navigation.NavigationController;
|
||||
import com.cappielloantonio.tempo.navigation.NavigationHelper;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.ui.activity.base.BaseActivity;
|
||||
import com.cappielloantonio.tempo.ui.controller.BottomSheetController;
|
||||
import com.cappielloantonio.tempo.ui.controller.BottomSheetHelper;
|
||||
import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
|
||||
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkNavigator;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
import com.google.android.material.color.DynamicColors;
|
||||
import com.google.android.material.navigation.NavigationView;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -52,10 +65,23 @@ public class MainActivity extends BaseActivity {
|
||||
private FragmentManager fragmentManager;
|
||||
private NavHostFragment navHostFragment;
|
||||
private BottomNavigationView bottomNavigationView;
|
||||
private FrameLayout bottomNavigationViewFrame;
|
||||
private DrawerLayout drawerLayout;
|
||||
private NavigationView navigationView;
|
||||
public NavController navController;
|
||||
private BottomSheetBehavior bottomSheetBehavior;
|
||||
private NavigationController navigationController;
|
||||
private BottomSheetController bottomSheetController;
|
||||
public BottomSheetBehavior bottomSheetBehavior;
|
||||
public boolean isLandscape = false;
|
||||
private AssetLinkNavigator assetLinkNavigator;
|
||||
private AssetLinkUtil.AssetLink pendingAssetLink;
|
||||
|
||||
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
|
||||
private Intent pendingDownloadPlaybackIntent;
|
||||
|
||||
public ActivityMainBinding getBinding() {
|
||||
return bind;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
@@ -69,26 +95,34 @@ public class MainActivity extends BaseActivity {
|
||||
setContentView(view);
|
||||
|
||||
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
|
||||
assetLinkNavigator = new AssetLinkNavigator(this);
|
||||
|
||||
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
|
||||
connectivityStatusReceiverManager(true);
|
||||
|
||||
isLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
|
||||
|
||||
init();
|
||||
checkConnectionType();
|
||||
getOpenSubsonicExtensions();
|
||||
checkTempoUpdate();
|
||||
|
||||
maybeSchedulePlaybackIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
pingServer();
|
||||
initService();
|
||||
consumePendingPlaybackIntent();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
pingServer();
|
||||
toggleNavigationDrawerLockOnOrientationChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,6 +132,14 @@ public class MainActivity extends BaseActivity {
|
||||
bind = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
maybeSchedulePlaybackIntent(intent);
|
||||
consumePendingPlaybackIntent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
|
||||
@@ -107,7 +149,6 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
public void init() {
|
||||
fragmentManager = getSupportFragmentManager();
|
||||
|
||||
initBottomSheet();
|
||||
initNavigation();
|
||||
@@ -117,51 +158,79 @@ public class MainActivity extends BaseActivity {
|
||||
} else {
|
||||
goToLogin();
|
||||
}
|
||||
|
||||
toggleNavigationDrawerLockOnOrientationChange();
|
||||
|
||||
}
|
||||
|
||||
// BOTTOM SHEET/NAVIGATION
|
||||
private void initBottomSheet() {
|
||||
bottomSheetBehavior = BottomSheetBehavior.from(findViewById(R.id.player_bottom_sheet));
|
||||
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback);
|
||||
fragmentManager.beginTransaction().replace(R.id.player_bottom_sheet, new PlayerBottomSheetFragment(), "PlayerBottomSheet").commit();
|
||||
private void initNavigation() {
|
||||
// We link the nav_graph.xml with our navigationController
|
||||
NavHostFragment navHostFragment = (NavHostFragment) this
|
||||
.getSupportFragmentManager()
|
||||
.findFragmentById(R.id.nav_host_fragment);
|
||||
navController = Objects.requireNonNull(navHostFragment).getNavController();
|
||||
/*
|
||||
navController is currently global since some legacy code still invokes it directly
|
||||
the MainActivity methods that use it must be converted to NavigationHelper methods
|
||||
*/
|
||||
|
||||
checkBottomSheetAfterStateChanged();
|
||||
// Helper
|
||||
NavigationHelper navigationHelper =
|
||||
new NavigationHelper(
|
||||
findViewById(R.id.bottom_navigation),
|
||||
findViewById(R.id.bottom_navigation_frame),
|
||||
findViewById(R.id.drawer_layout),
|
||||
findViewById(R.id.nav_view),
|
||||
navHostFragment
|
||||
);
|
||||
|
||||
// Controller
|
||||
navigationController = new NavigationController(navigationHelper);
|
||||
navigationController.syncWithBottomSheetBehavior(bottomSheetBehavior, navController);
|
||||
}
|
||||
|
||||
private void initBottomSheet() {
|
||||
FragmentManager fragmentManager = getSupportFragmentManager();
|
||||
View bottomSheetView = findViewById(R.id.player_bottom_sheet);
|
||||
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetView);
|
||||
/*
|
||||
bottomSheetBehavior is currently global since some legacy code still invokes it directly
|
||||
the MainActivity methods that use it must be converted to BottomSheetHelper methods
|
||||
*/
|
||||
|
||||
// Helper
|
||||
BottomSheetHelper bottomSheetHelper =
|
||||
new BottomSheetHelper(
|
||||
bottomSheetBehavior,
|
||||
bottomSheetView,
|
||||
fragmentManager
|
||||
);
|
||||
|
||||
// Controller
|
||||
bottomSheetController = new BottomSheetController(bottomSheetHelper);
|
||||
bottomSheetController.addCallback(bottomSheetCallback);
|
||||
bottomSheetController.replaceFragment(R.id.player_bottom_sheet);
|
||||
bottomSheetController.checkAfterStateChanged(mainViewModel);
|
||||
}
|
||||
|
||||
public void setBottomSheetInPeek(Boolean isVisible) {
|
||||
if (isVisible) {
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
} else {
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
|
||||
}
|
||||
bottomSheetController.setStateInPeek(isVisible);
|
||||
}
|
||||
|
||||
public void setBottomSheetVisibility(boolean visibility) {
|
||||
if (visibility) {
|
||||
findViewById(R.id.player_bottom_sheet).setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
findViewById(R.id.player_bottom_sheet).setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkBottomSheetAfterStateChanged() {
|
||||
final Handler handler = new Handler();
|
||||
final Runnable runnable = () -> setBottomSheetInPeek(mainViewModel.isQueueLoaded());
|
||||
handler.postDelayed(runnable, 100);
|
||||
bottomSheetController.setVisibility(visibility);
|
||||
}
|
||||
|
||||
public void collapseBottomSheetDelayed() {
|
||||
final Handler handler = new Handler();
|
||||
final Runnable runnable = () -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
handler.postDelayed(runnable, 100);
|
||||
bottomSheetController.collapseDelayed();
|
||||
}
|
||||
|
||||
public void expandBottomSheet() {
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
|
||||
bottomSheetController.expand();
|
||||
}
|
||||
|
||||
public void setBottomSheetDraggableState(Boolean isDraggable) {
|
||||
bottomSheetBehavior.setDraggable(isDraggable);
|
||||
bottomSheetController.setDraggable(isDraggable);
|
||||
}
|
||||
|
||||
private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
|
||||
@@ -174,7 +243,7 @@ public class MainActivity extends BaseActivity {
|
||||
|
||||
switch (state) {
|
||||
case BottomSheetBehavior.STATE_HIDDEN:
|
||||
resetMusicSession();
|
||||
resetMusicSession(); // I can't put the callback inside BottomSheetHelper because of this line
|
||||
break;
|
||||
case BottomSheetBehavior.STATE_COLLAPSED:
|
||||
if (playerBottomSheetFragment != null)
|
||||
@@ -191,17 +260,14 @@ public class MainActivity extends BaseActivity {
|
||||
@Override
|
||||
public void onSlide(@NonNull View view, float slideOffset) {
|
||||
animateBottomSheet(slideOffset);
|
||||
animateBottomNavigation(slideOffset, navigationHeight);
|
||||
if (!isLandscape) {
|
||||
animateBottomNavigation(slideOffset, navigationHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private void animateBottomSheet(float slideOffset) {
|
||||
PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet");
|
||||
if (playerBottomSheetFragment != null) {
|
||||
float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f;
|
||||
playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset);
|
||||
playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
bottomSheetController.animate(slideOffset);
|
||||
}
|
||||
|
||||
private void animateBottomNavigation(float slideOffset, int navigationHeight) {
|
||||
@@ -216,36 +282,57 @@ public class MainActivity extends BaseActivity {
|
||||
bind.bottomNavigation.setTranslationY(slideY);
|
||||
}
|
||||
|
||||
private void initNavigation() {
|
||||
bottomNavigationView = findViewById(R.id.bottom_navigation);
|
||||
navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment);
|
||||
navController = Objects.requireNonNull(navHostFragment).getNavController();
|
||||
|
||||
/*
|
||||
* In questo modo intercetto il cambio schermata tramite navbar e se il bottom sheet è aperto,
|
||||
* lo chiudo
|
||||
*/
|
||||
navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
|
||||
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED && (
|
||||
destination.getId() == R.id.homeFragment ||
|
||||
destination.getId() == R.id.libraryFragment ||
|
||||
destination.getId() == R.id.downloadFragment)
|
||||
) {
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
});
|
||||
|
||||
NavigationUI.setupWithNavController(bottomNavigationView, navController);
|
||||
public void setBottomNavigationBarVisibility(boolean visibility) {
|
||||
navigationController.setNavbarVisibility(visibility);
|
||||
}
|
||||
|
||||
public void setBottomNavigationBarVisibility(boolean visibility) {
|
||||
if (visibility) {
|
||||
bottomNavigationView.setVisibility(View.VISIBLE);
|
||||
public void toggleBottomNavigationBarVisibilityOnOrientationChange() {
|
||||
float displayDensity = getResources().getDisplayMetrics().density;
|
||||
// Ignore orientation change, bottom navbar always hidden
|
||||
if (Preferences.getHideBottomNavbarOnPortrait()) {
|
||||
navigationController.setNavbarVisibility(false);
|
||||
bottomSheetController.setPeekHeight(56, displayDensity);
|
||||
navigationController.setSystemBarsVisibility(this, !isLandscape);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLandscape) {
|
||||
// Show app navbar + show system bars
|
||||
bottomSheetController.setPeekHeight(136, displayDensity);
|
||||
navigationController.setNavbarVisibility(true);
|
||||
navigationController.setSystemBarsVisibility(this, true);
|
||||
} else {
|
||||
bottomNavigationView.setVisibility(View.GONE);
|
||||
// Hide app navbar + hide system bars
|
||||
bottomSheetController.setPeekHeight(56, displayDensity);
|
||||
navigationController.setNavbarVisibility(false);
|
||||
navigationController.setSystemBarsVisibility(this, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void setNavigationDrawerLock(boolean locked) {
|
||||
navigationController.setDrawerLock(locked);
|
||||
}
|
||||
|
||||
public boolean isNavigationDrawerLocked() {
|
||||
return navigationController.isNavigationDrawerLocked();
|
||||
}
|
||||
|
||||
public void toggleNavigationDrawerLockOnOrientationChange() {
|
||||
navigationController.toggleDrawerLockOnOrientation(this);
|
||||
}
|
||||
|
||||
public void setSystemBarsVisibility(boolean visibility) {
|
||||
navigationController.setSystemBarsVisibility(this, visibility);
|
||||
}
|
||||
|
||||
/*
|
||||
There are only 4 init functions that must exist up to here
|
||||
1. init()
|
||||
2. initNavigation()
|
||||
3. initBottomSheet()
|
||||
4. bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { ... }
|
||||
*/
|
||||
|
||||
private void initService() {
|
||||
MediaManager.check(getMediaBrowserListenableFuture());
|
||||
|
||||
@@ -280,7 +367,7 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
private void goToHome() {
|
||||
bottomNavigationView.setVisibility(View.VISIBLE);
|
||||
setBottomNavigationBarVisibility(true);
|
||||
|
||||
if (Objects.requireNonNull(navController.getCurrentDestination()).getId() == R.id.landingFragment) {
|
||||
navController.navigate(R.id.action_landingFragment_to_homeFragment);
|
||||
@@ -292,6 +379,24 @@ public class MainActivity extends BaseActivity {
|
||||
public void goFromLogin() {
|
||||
setBottomSheetInPeek(mainViewModel.isQueueLoaded());
|
||||
goToHome();
|
||||
consumePendingAssetLink();
|
||||
}
|
||||
|
||||
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) {
|
||||
openAssetLink(assetLink, true);
|
||||
}
|
||||
|
||||
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) {
|
||||
if (!isUserAuthenticated()) {
|
||||
pendingAssetLink = assetLink;
|
||||
return;
|
||||
}
|
||||
if (collapsePlayer) {
|
||||
setBottomSheetInPeek(true);
|
||||
}
|
||||
if (assetLinkNavigator != null) {
|
||||
assetLinkNavigator.open(assetLink);
|
||||
}
|
||||
}
|
||||
|
||||
public void quit() {
|
||||
@@ -309,13 +414,15 @@ public class MainActivity extends BaseActivity {
|
||||
Preferences.setServer(null);
|
||||
Preferences.setLocalAddress(null);
|
||||
Preferences.setUser(null);
|
||||
Preferences.setClientCert(null);
|
||||
|
||||
// TODO Enter all settings to be reset
|
||||
Preferences.setOpenSubsonic(false);
|
||||
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_100);
|
||||
Preferences.setPlaybackSpeed(1.0f);
|
||||
Preferences.setSkipSilenceMode(false);
|
||||
Preferences.setDataSavingMode(false);
|
||||
Preferences.setStarredSyncEnabled(false);
|
||||
Preferences.setStarredAlbumsSyncEnabled(false);
|
||||
}
|
||||
|
||||
private void resetMusicSession() {
|
||||
@@ -341,7 +448,7 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
private void pingServer() {
|
||||
if (Preferences.getToken() == null) return;
|
||||
if (Preferences.getToken() == null && Preferences.getPassword() == null) return;
|
||||
|
||||
if (Preferences.isInUseServerAddressLocal()) {
|
||||
mainViewModel.ping().observe(this, subsonicResponse -> {
|
||||
@@ -350,6 +457,7 @@ public class MainActivity extends BaseActivity {
|
||||
Preferences.switchInUseServerAddress();
|
||||
App.refreshSubsonicClient();
|
||||
pingServer();
|
||||
resetView();
|
||||
} else {
|
||||
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
|
||||
}
|
||||
@@ -360,6 +468,7 @@ public class MainActivity extends BaseActivity {
|
||||
Preferences.switchInUseServerAddress();
|
||||
App.refreshSubsonicClient();
|
||||
pingServer();
|
||||
resetView();
|
||||
} else {
|
||||
mainViewModel.ping().observe(this, subsonicResponse -> {
|
||||
if (subsonicResponse == null) {
|
||||
@@ -375,8 +484,15 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void resetView() {
|
||||
resetViewModel();
|
||||
int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
|
||||
navController.popBackStack(id, true);
|
||||
navController.navigate(id);
|
||||
}
|
||||
|
||||
private void getOpenSubsonicExtensions() {
|
||||
if (Preferences.getToken() != null) {
|
||||
if (Preferences.getToken() != null || Preferences.getPassword() != null) {
|
||||
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
|
||||
if (openSubsonicExtensions != null) {
|
||||
Preferences.setOpenSubsonicExtensions(openSubsonicExtensions);
|
||||
@@ -386,7 +502,7 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
private void checkTempoUpdate() {
|
||||
if (BuildConfig.FLAVOR.equals("tempo") && Preferences.showTempoUpdateDialog()) {
|
||||
if (BuildConfig.FLAVOR.equals("tempus") && Preferences.isGithubUpdateEnabled() && Preferences.showTempusUpdateDialog()) {
|
||||
mainViewModel.checkTempoUpdate().observe(this, latestRelease -> {
|
||||
if (latestRelease != null && UpdateUtil.showUpdateDialog(latestRelease)) {
|
||||
GithubTempoUpdateDialog dialog = new GithubTempoUpdateDialog(latestRelease);
|
||||
@@ -407,4 +523,98 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeSchedulePlaybackIntent(Intent intent) {
|
||||
if (intent == null) return;
|
||||
if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
|
||||
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
|
||||
pendingDownloadPlaybackIntent = new Intent(intent);
|
||||
}
|
||||
handleAssetLinkIntent(intent);
|
||||
}
|
||||
|
||||
private void consumePendingPlaybackIntent() {
|
||||
if (pendingDownloadPlaybackIntent == null) return;
|
||||
Intent intent = pendingDownloadPlaybackIntent;
|
||||
pendingDownloadPlaybackIntent = null;
|
||||
playDownloadedMedia(intent);
|
||||
}
|
||||
|
||||
private void handleAssetLinkIntent(Intent intent) {
|
||||
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent);
|
||||
if (assetLink == null) {
|
||||
return;
|
||||
}
|
||||
if (!isUserAuthenticated()) {
|
||||
pendingAssetLink = assetLink;
|
||||
intent.setData(null);
|
||||
return;
|
||||
}
|
||||
if (assetLinkNavigator != null) {
|
||||
assetLinkNavigator.open(assetLink);
|
||||
}
|
||||
intent.setData(null);
|
||||
}
|
||||
|
||||
private boolean isUserAuthenticated() {
|
||||
return Preferences.getPassword() != null
|
||||
|| (Preferences.getToken() != null && Preferences.getSalt() != null);
|
||||
}
|
||||
|
||||
private void consumePendingAssetLink() {
|
||||
if (pendingAssetLink == null || assetLinkNavigator == null) {
|
||||
return;
|
||||
}
|
||||
assetLinkNavigator.open(pendingAssetLink);
|
||||
pendingAssetLink = null;
|
||||
}
|
||||
|
||||
private void playDownloadedMedia(Intent intent) {
|
||||
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = Uri.parse(uriString);
|
||||
String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
|
||||
if (TextUtils.isEmpty(mediaId)) {
|
||||
mediaId = uri.toString();
|
||||
}
|
||||
|
||||
String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
|
||||
String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
|
||||
String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
|
||||
int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
|
||||
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString("id", mediaId);
|
||||
extras.putString("title", title);
|
||||
extras.putString("artist", artist);
|
||||
extras.putString("album", album);
|
||||
extras.putString("uri", uri.toString());
|
||||
extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
|
||||
extras.putInt("duration", duration);
|
||||
|
||||
MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
|
||||
.setExtras(extras)
|
||||
.setIsBrowsable(false)
|
||||
.setIsPlayable(true);
|
||||
|
||||
if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
|
||||
if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
|
||||
if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
|
||||
|
||||
MediaItem mediaItem = new MediaItem.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setMediaMetadata(metadataBuilder.build())
|
||||
.setUri(uri)
|
||||
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||
.setRequestMetadata(new MediaItem.RequestMetadata.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setExtras(extras)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.TileSizeManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -22,6 +23,8 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
|
||||
|
||||
private List<AlbumID3> albums;
|
||||
|
||||
private int sizePx = 400;
|
||||
|
||||
public AlbumAdapter(ClickCallback click) {
|
||||
this.click = click;
|
||||
this.albums = Collections.emptyList();
|
||||
@@ -31,11 +34,20 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
ItemLibraryAlbumBinding view = ItemLibraryAlbumBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
|
||||
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
|
||||
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
ViewGroup.LayoutParams lp = holder.item.albumCoverImageView.getLayoutParams();
|
||||
lp.width = sizePx;
|
||||
lp.height = sizePx;
|
||||
holder.item.albumCoverImageView.setLayoutParams(lp);
|
||||
|
||||
AlbumID3 album = albums.get(position);
|
||||
|
||||
holder.item.albumNameLabel.setText(album.getName());
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
|
||||
@@ -151,13 +152,27 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
||||
}
|
||||
}
|
||||
|
||||
public void setItemsWithoutFilter(List<AlbumID3> albums) {
|
||||
this.albumsFull = new ArrayList<>(albums);
|
||||
this.albums = new ArrayList<>(albums);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void sort(String order) {
|
||||
if (albums == null) return;
|
||||
|
||||
switch (order) {
|
||||
case Constants.ALBUM_ORDER_BY_NAME:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getName));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getName() != null ? album.getName() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_ARTIST:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getArtist() != null ? album.getArtist() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_YEAR:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getYear));
|
||||
@@ -166,15 +181,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
||||
Collections.shuffle(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getCreated));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayed));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayed() != null ? album.getPlayed() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayCount));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayCount() != null ? album.getPlayCount() : 0L
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.TileSizeManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -24,6 +25,7 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
|
||||
private final boolean mix;
|
||||
private final boolean bestOf;
|
||||
|
||||
private int sizePx = 400;
|
||||
private List<ArtistID3> artists;
|
||||
|
||||
public ArtistAdapter(ClickCallback click, Boolean mix, Boolean bestOf) {
|
||||
@@ -37,11 +39,20 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
ItemLibraryArtistBinding view = ItemLibraryArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
|
||||
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
|
||||
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
ViewGroup.LayoutParams lp = holder.item.artistCoverImageView.getLayoutParams();
|
||||
lp.width = sizePx;
|
||||
lp.height = sizePx;
|
||||
holder.item.artistCoverImageView.setLayoutParams(lp);
|
||||
|
||||
ArtistID3 artist = artists.get(position);
|
||||
|
||||
holder.item.artistNameLabel.setText(artist.getName());
|
||||
|
||||
@@ -146,11 +146,14 @@ public class ArtistCatalogueAdapter extends RecyclerView.Adapter<ArtistCatalogue
|
||||
public void sort(String order) {
|
||||
switch (order) {
|
||||
case Constants.ARTIST_ORDER_BY_NAME:
|
||||
artists.sort(Comparator.comparing(ArtistID3::getName));
|
||||
artists.sort(Comparator.comparing(ArtistID3::getName,String.CASE_INSENSITIVE_ORDER));
|
||||
break;
|
||||
case Constants.ARTIST_ORDER_BY_RANDOM:
|
||||
Collections.shuffle(artists);
|
||||
break;
|
||||
case Constants.ARTIST_ORDER_BY_ALBUM_COUNT:
|
||||
artists.sort(Comparator.comparing(ArtistID3::getAlbumCount).reversed());
|
||||
break;
|
||||
}
|
||||
|
||||
notifyDataSetChanged();
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SimilarArtistID3;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.TileSizeManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -22,6 +23,8 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
|
||||
|
||||
private List<SimilarArtistID3> artists;
|
||||
|
||||
private int sizePx = 400;
|
||||
|
||||
public ArtistSimilarAdapter(ClickCallback click) {
|
||||
this.click = click;
|
||||
this.artists = Collections.emptyList();
|
||||
@@ -31,11 +34,20 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
ItemLibrarySimilarArtistBinding view = ItemLibrarySimilarArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
|
||||
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
|
||||
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
ViewGroup.LayoutParams lp = holder.item.similarArtistCoverImageView.getLayoutParams();
|
||||
lp.width = sizePx;
|
||||
lp.height = sizePx;
|
||||
holder.item.similarArtistCoverImageView.setLayoutParams(lp);
|
||||
|
||||
SimilarArtistID3 artist = artists.get(position);
|
||||
|
||||
holder.item.artistNameLabel.setText(artist.getName());
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.AccelerateDecelerateInterpolator;
|
||||
|
||||
@@ -14,6 +15,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.TileSizeManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -23,6 +25,9 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
|
||||
private List<Child> songs;
|
||||
|
||||
private int widthPx = 800;
|
||||
private int heightPx = 400;
|
||||
|
||||
public DiscoverSongAdapter(ClickCallback click) {
|
||||
this.click = click;
|
||||
this.songs = Collections.emptyList();
|
||||
@@ -32,11 +37,21 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
ItemHomeDiscoverSongBinding view = ItemHomeDiscoverSongBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
|
||||
|
||||
TileSizeManager.getInstance().calculateDiscoverSize(parent.getContext());
|
||||
widthPx = TileSizeManager.getInstance().getDiscoverWidthPx(parent.getContext());;
|
||||
heightPx = TileSizeManager.getInstance().getDiscoverHeightPx(parent.getContext());;
|
||||
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
ViewGroup.LayoutParams lp = holder.item.discoverSongCoverImageView.getLayoutParams();
|
||||
lp.width = widthPx;
|
||||
lp.height = heightPx;
|
||||
holder.item.discoverSongCoverImageView.setLayoutParams(lp);
|
||||
|
||||
Child song = songs.get(position);
|
||||
|
||||
holder.item.titleDiscoverSongLabel.setText(song.getTitle());
|
||||
@@ -73,6 +88,11 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
this.item = item;
|
||||
|
||||
itemView.setOnClickListener(v -> onClick());
|
||||
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
onLongClick();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
@@ -82,6 +102,13 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
|
||||
click.onMediaClick(bundle);
|
||||
}
|
||||
|
||||
private boolean onLongClick() {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
|
||||
click.onMediaLongClick(bundle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void startAnimation(ViewHolder holder) {
|
||||
|
||||
@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
|
||||
R.string.song_subtitle_formatter,
|
||||
song.getArtist(),
|
||||
MusicUtil.getReadableDurationString(song.getDuration(), false),
|
||||
""
|
||||
MusicUtil.getReadableAudioQualityString(song)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -42,8 +42,13 @@ public class InternetRadioStationAdapter extends RecyclerView.Adapter<InternetRa
|
||||
holder.item.internetRadioStationTitleTextView.setText(internetRadioStation.getName());
|
||||
holder.item.internetRadioStationSubtitleTextView.setText(internetRadioStation.getStreamUrl());
|
||||
|
||||
String imageId = internetRadioStation.getHomePageUrl();
|
||||
if (imageId == null || imageId.isEmpty()) {
|
||||
imageId = internetRadioStation.getStreamUrl();
|
||||
}
|
||||
|
||||
CustomGlideRequest.Builder
|
||||
.from(holder.itemView.getContext(), internetRadioStation.getStreamUrl(), CustomGlideRequest.ResourceType.Radio)
|
||||
.from(holder.itemView.getContext(), imageId, CustomGlideRequest.ResourceType.Radio)
|
||||
.build()
|
||||
.into(holder.item.internetRadioStationCoverImageView);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
.into(holder.item.musicDirectoryCoverImageView);
|
||||
|
||||
holder.item.musicDirectoryMoreButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
|
||||
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.INVISIBLE : View.VISIBLE);
|
||||
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -80,6 +80,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
itemView.setOnLongClickListener(v -> onLongClick());
|
||||
|
||||
item.musicDirectoryMoreButton.setOnClickListener(v -> onClick());
|
||||
item.musicDirectoryPlayButton.setOnClickListener(v -> onPlayClick());
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
@@ -107,5 +108,13 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void onPlayClick() {
|
||||
if (children.get(getBindingAdapterPosition()).isDir()) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(Constants.MUSIC_DIRECTORY_ID, children.get(getBindingAdapterPosition()).getId());
|
||||
click.onMusicDirectoryPlay(bundle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user