Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12025ab59a | ||
|
|
f66840e8e4 | ||
|
|
4c3e098785 | ||
|
|
441086e773 | ||
|
|
1408a4c3ff | ||
|
|
fe67a221ac | ||
|
|
f087cbed6f | ||
|
|
9820db0ef6 | ||
|
|
9664f79b1f | ||
|
|
533396b855 | ||
|
|
005a051cd0 | ||
|
|
f06168fdc2 | ||
|
|
26c6b9149b | ||
|
|
8b70d02332 | ||
|
|
4370b19e12 | ||
|
|
f708e4dc07 | ||
|
|
4bb809c1b9 | ||
|
|
ccf57bb016 | ||
|
|
65b2a1a9a4 | ||
|
|
8e6ef8e718 | ||
|
|
cbea6229b5 | ||
|
|
05205eb646 | ||
|
|
32fc866d8a | ||
|
|
da089338dc | ||
|
|
e19f47b291 | ||
|
|
e7ed54439b | ||
|
|
3db3bbd756 | ||
|
|
24ab13e27e | ||
|
|
195d1d1ef4 | ||
|
|
5383e91249 | ||
|
|
c02e45b703 | ||
|
|
991dd997ee | ||
|
|
daddd5c3c2 | ||
|
|
8b77fae668 | ||
|
|
bba15dacf8 | ||
|
|
bfaeb6ea42 | ||
|
|
5f66f65d6f | ||
|
|
8b9098c877 | ||
|
|
7044925791 | ||
|
|
bcc292b33a | ||
|
|
ae08206c3e | ||
|
|
91315dd9b5 | ||
|
|
8a253e7679 | ||
|
|
04e9c8a329 | ||
|
|
1d163df773 | ||
|
|
4cfa22004c | ||
|
|
e0747e3f19 | ||
|
|
b14e7b7357 | ||
|
|
0123df99d2 | ||
|
|
4a48cb0a9f | ||
|
|
425ef4c3b6 | ||
|
|
e5ef38295d | ||
|
|
b084e54dcc | ||
|
|
d3e55cabf4 | ||
|
|
2cc9ff5a41 | ||
|
|
9562e8f252 | ||
|
|
7726192008 | ||
|
|
f8424c03fd | ||
|
|
58fbfca645 | ||
|
|
6282fd1f95 | ||
|
|
245df5d934 | ||
|
|
dc6184f41d | ||
|
|
b3c3950c55 | ||
|
|
034a134319 | ||
|
|
8142d626c0 | ||
|
|
8b20730b6f | ||
|
|
d27f431f6f | ||
|
|
0df0a206e7 | ||
|
|
6aeb31b591 | ||
|
|
eb2483d039 | ||
|
|
67fbeeeb6c | ||
|
|
5099e39f7b | ||
|
|
34710dbddc | ||
|
|
4798644bea | ||
|
|
bd0cbf8bf0 | ||
|
|
f562a1a5dc | ||
|
|
185cba16eb | ||
|
|
547d086228 | ||
|
|
3a2d5647d4 | ||
|
|
0a67c9b802 | ||
|
|
0254afd5d8 | ||
|
|
853546f3d2 | ||
|
|
b8ac073247 | ||
|
|
56cb4df31a | ||
|
|
7285b0bf26 | ||
|
|
3fdc2e6dcf | ||
|
|
ed2f9d2e45 | ||
|
|
68dafbba75 | ||
|
|
7d17367c1e | ||
|
|
8be08702c1 | ||
|
|
91bc56e1e1 | ||
|
|
2c2e77f6cd | ||
|
|
12d02645fd | ||
|
|
4578339594 | ||
|
|
b81f78755d | ||
|
|
82664d6c81 | ||
|
|
9e06db58d2 | ||
|
|
de3461af52 | ||
|
|
61f676d106 | ||
|
|
82627b85a5 | ||
|
|
21bc6cc8b6 | ||
|
|
2295b1071e | ||
|
|
c75e376533 | ||
|
|
3a5e2af9e4 | ||
|
|
30f1414ea5 | ||
|
|
737364aa3a | ||
|
|
fd988afad1 | ||
|
|
56dc7f856c | ||
|
|
5e4a41b0b5 | ||
|
|
66f426f6bb | ||
|
|
03efa352f5 | ||
|
|
5c147955b7 | ||
|
|
0a55573bd0 | ||
|
|
de3812ac33 | ||
|
|
b6de52eb91 | ||
|
|
65ec1ec5d4 | ||
|
|
614118d1d3 | ||
|
|
e3999360f0 | ||
|
|
50d1b62b95 | ||
|
|
d4e7d0abf9 | ||
|
|
1571bcd9f3 | ||
|
|
5531ced129 | ||
|
|
2b258de5b8 | ||
|
|
4e3d3f7752 | ||
|
|
63863a8800 | ||
|
|
d594fcb08b | ||
|
|
85aab8df06 | ||
|
|
ec1585f7c1 | ||
|
|
3b3bda117c | ||
|
|
c37f2b71d4 | ||
|
|
04de5960f1 | ||
|
|
dad7ac4efd | ||
|
|
d2830c1603 | ||
|
|
6091fd6f79 | ||
|
|
c9935eca2b | ||
|
|
38eacdd7b4 | ||
|
|
1bde917b09 | ||
|
|
0859e219fa | ||
|
|
dac9279320 | ||
|
|
4fb9f6b92d | ||
|
|
fabd579247 | ||
|
|
b4ee3b80a8 | ||
|
|
a75bdce9b4 | ||
|
|
d48ac952a5 | ||
|
|
d83b2e032b | ||
|
|
46c9c3f006 | ||
|
|
4199add608 | ||
|
|
8311643e73 | ||
|
|
7775d5216f | ||
|
|
6f2fc6b5c1 | ||
|
|
235cac7ab4 | ||
|
|
93b4acfe13 | ||
|
|
bd7c73c48d | ||
|
|
45ecca7ce4 | ||
|
|
0a8c67cf13 | ||
|
|
316d3d8324 | ||
|
|
182d45b07c | ||
|
|
58d8afca38 | ||
|
|
136709d7ae | ||
|
|
b0d02aa769 | ||
|
|
ca66a45ba4 | ||
|
|
d7d4114ee6 | ||
|
|
e589c0a7ea | ||
|
|
eb2625a2df | ||
|
|
aacbacf557 | ||
|
|
0a7b129db2 | ||
|
|
c927264b62 | ||
|
|
808225351e | ||
|
|
40ee8d5221 | ||
|
|
8cc80e8378 | ||
|
|
db5f249c14 | ||
|
|
e86028e577 | ||
|
|
54bed2bee1 | ||
|
|
1de78165d0 | ||
|
|
4c9ed5e2bf | ||
|
|
a036a8bda6 | ||
|
|
5a482e603e | ||
|
|
d5eb8a341e | ||
|
|
9c74c47448 | ||
|
|
8584bebe6c | ||
|
|
39cd6c65f2 | ||
|
|
1056541dd2 | ||
|
|
f9a3a4a218 | ||
|
|
80bafd9281 | ||
|
|
c06c17d3ae | ||
|
|
2a7969fe68 | ||
|
|
decda748ba | ||
|
|
427f858be5 | ||
|
|
1043fb3f3e | ||
|
|
7c5e8cb046 | ||
|
|
5fb8baa186 | ||
|
|
f63b04451b | ||
|
|
a80722ff79 | ||
|
|
9d5cbd37c8 | ||
|
|
fada8fccb1 | ||
|
|
9d650019cd | ||
|
|
915f3c88bc | ||
|
|
140bc7fbc5 | ||
|
|
2a12c1e811 | ||
|
|
5250adb36e | ||
|
|
f548163689 | ||
|
|
589ffcecca | ||
|
|
d9c591b0ea | ||
|
|
462021225f | ||
|
|
3f0bc74c21 | ||
|
|
b2c2b625e3 | ||
|
|
d3bd3427e3 | ||
|
|
a040a73174 | ||
|
|
332d9f203e | ||
|
|
2d97329836 | ||
|
|
730d030623 | ||
|
|
2e29daab1d | ||
|
|
d7b48b4f61 | ||
|
|
24b98f10e3 | ||
|
|
5fd6f5e4cc | ||
|
|
c21af649c3 | ||
|
|
1d8a23922e | ||
|
|
68893ab1f4 | ||
|
|
dcf172bd98 | ||
|
|
6dd466a9ce | ||
|
|
ca222d4110 | ||
|
|
c9af9582e3 | ||
|
|
a3028127ce | ||
|
|
23133194b2 | ||
|
|
b84555db6c | ||
|
|
229298df1b | ||
|
|
fb270cb50b | ||
|
|
a861c880ba | ||
|
|
9712d3f527 | ||
|
|
51a7ce41de | ||
|
|
4c657c66d0 | ||
|
|
61c3fafd94 | ||
|
|
bab95530c8 | ||
|
|
68afba45bc | ||
|
|
1a52f19b32 | ||
|
|
aaef0a7e8d | ||
|
|
02d9023f9d | ||
|
|
59a27cdd4d | ||
|
|
9440782027 | ||
|
|
f19ec05780 | ||
|
|
d95b75e317 | ||
|
|
a6dec31461 | ||
|
|
b12427daa6 | ||
|
|
892686b213 | ||
|
|
81e71cd7a2 | ||
|
|
38a5e3637d | ||
|
|
eb16e769fe | ||
|
|
9417f0dd3d | ||
|
|
68d82f7a3a | ||
|
|
b8fa041dcb | ||
|
|
1df07dc58d | ||
|
|
e63358f776 | ||
|
|
9518189898 | ||
|
|
70a9cab6f2 | ||
|
|
495ec80ebd | ||
|
|
34454fee10 | ||
|
|
fbac4be4a5 | ||
|
|
a150e6109e | ||
|
|
f392075836 | ||
|
|
5d9dd394ea | ||
|
|
162c189d4f | ||
|
|
01902f8a79 | ||
|
|
399a01a937 | ||
|
|
dcaa19071f | ||
|
|
a6280805e6 | ||
|
|
3d8843109a | ||
|
|
16eaccc8e3 | ||
|
|
9df996a507 | ||
|
|
fa0b9ed6ab | ||
|
|
9b8536a581 | ||
|
|
9158f4f8f1 | ||
|
|
072f19e157 | ||
|
|
44198fa17a | ||
|
|
83ccb398c5 | ||
|
|
824e335dec | ||
|
|
e28e9d71ae | ||
|
|
46acc76f07 | ||
|
|
356fa4eb7c | ||
|
|
09a6c631cb | ||
|
|
cf19816e06 | ||
|
|
2c4b9f5a50 | ||
|
|
9e29d45fc3 | ||
|
|
b4e350a308 | ||
|
|
8274072b6a | ||
|
|
1ddd146558 | ||
|
|
2d76fc6e8c | ||
|
|
e19e541a70 | ||
|
|
3a56c82501 | ||
|
|
2f0e385ca2 | ||
|
|
9241a4729c | ||
|
|
ea331bd4ef | ||
|
|
c2b598d98e | ||
|
|
2fde05412c | ||
|
|
d8436d3273 | ||
|
|
e662dae396 | ||
|
|
e74c5d8609 | ||
|
|
3f05a8408d | ||
|
|
61f0ba4643 | ||
|
|
b773ec7690 | ||
|
|
df294730a0 | ||
|
|
ee1cd38fa2 | ||
|
|
a012328f4c | ||
|
|
1df9656191 | ||
|
|
fde2d0887e | ||
|
|
356e439715 | ||
|
|
00ee7b662e | ||
|
|
b222b2a5e5 | ||
|
|
15b212734e | ||
|
|
631ba52ffb | ||
|
|
a4b58a893d | ||
|
|
47a3fd9462 | ||
|
|
abcd56f0c5 | ||
|
|
b498b5eafa | ||
|
|
be114e7789 | ||
|
|
8c44142e70 | ||
|
|
5a655f77d2 | ||
|
|
c825f791d0 | ||
|
|
83a9d8a162 | ||
|
|
acfa2e2183 | ||
|
|
591933f3d8 | ||
|
|
982f94d955 | ||
|
|
95bf28d7c6 | ||
|
|
48a299de45 | ||
|
|
719e517dda | ||
|
|
361c55c5ac | ||
|
|
b157fff229 | ||
|
|
e23e6417ee | ||
|
|
ad77209832 | ||
|
|
4ceb427d9c | ||
|
|
16a26130f2 | ||
|
|
e7773c9e88 | ||
|
|
1b38b92177 | ||
|
|
ef180f08b0 | ||
|
|
941e823374 | ||
|
|
6de45a3b88 | ||
|
|
ceb9537b73 | ||
|
|
976bf73388 | ||
|
|
6c7f2d96f3 | ||
|
|
ae2fc1fcfb | ||
|
|
3aad7e9e07 | ||
|
|
e43f1e75c8 | ||
|
|
bc5505e12d | ||
|
|
19a2703ea4 | ||
|
|
8a8f7d4f27 | ||
|
|
e350b4970f | ||
|
|
4be28bc579 | ||
|
|
13855a8682 | ||
|
|
4be1598eca | ||
|
|
7aebcd92c3 | ||
|
|
bd8668f0bf | ||
|
|
c6a7c6fec2 | ||
|
|
e367fa8a86 | ||
|
|
ecdbabfa17 | ||
|
|
796189ad48 | ||
|
|
76d1f2b29b | ||
|
|
73599815ba | ||
|
|
66372e9743 | ||
|
|
0d7f92129b | ||
|
|
f74a1ceb47 | ||
|
|
9da49d6116 | ||
|
|
7eeb9095e8 | ||
|
|
58281bb5ce | ||
|
|
f24dd3b289 | ||
|
|
51f2e5e7ec | ||
|
|
a937800ef0 | ||
|
|
c34d14edb5 | ||
|
|
8722e91c0f | ||
|
|
8038a8ac66 | ||
|
|
69a54c5fa9 | ||
|
|
aa508887f3 | ||
|
|
a53a96ecf5 | ||
|
|
82cddde15f | ||
|
|
def47fd5e2 | ||
|
|
04c70f54aa | ||
|
|
03e2feddb3 | ||
|
|
fc69909dd0 | ||
|
|
15fa42986a | ||
|
|
d8b4cf98dd | ||
|
|
ce62f3b273 | ||
|
|
8be2e3af0c | ||
|
|
e44c574068 | ||
|
|
c31aa17ce2 | ||
|
|
decac596d0 | ||
|
|
2d9604aa07 | ||
|
|
79c4ffa6c5 | ||
|
|
571b3a2c86 | ||
|
|
fa31fba586 | ||
|
|
2f3b261392 | ||
|
|
afd3cd6716 | ||
|
|
be99ef87ad | ||
|
|
572fc8545f | ||
|
|
239a943172 | ||
|
|
87614bc78a | ||
|
|
224d75fac5 | ||
|
|
f4e0a6121f | ||
|
|
80ee88b488 | ||
|
|
beb654825f | ||
|
|
79d8ccbf52 | ||
|
|
b48c74eca0 | ||
|
|
19e3dee706 | ||
|
|
e1be86c3ce | ||
|
|
11f7eddd7b | ||
|
|
7f28d2ea70 | ||
|
|
4607a8e274 | ||
|
|
26c559104a | ||
|
|
e15e884e65 | ||
|
|
b29401909f | ||
|
|
beb5e1a56c | ||
|
|
be08efeca3 | ||
|
|
4534ffaead | ||
|
|
d35dd2df1a | ||
|
|
2f3a9a5d96 | ||
|
|
fab94cd363 | ||
|
|
b06274866d | ||
|
|
5766c49f61 | ||
|
|
bc927d4c35 | ||
|
|
5804c54656 | ||
|
|
52f86cbce9 | ||
|
|
f6e2e4010f | ||
|
|
44613e12f1 | ||
|
|
b99e0b112b | ||
|
|
80b565c23f | ||
|
|
bee88c43e5 | ||
|
|
467fedbfe7 | ||
|
|
ba4e9ab7e3 | ||
|
|
1b4fa5e44a | ||
|
|
e92c9b2990 | ||
|
|
f23f8e9ade | ||
|
|
dc00aeece4 | ||
|
|
d8cdb7073e | ||
|
|
4fdea9b0ec | ||
|
|
3ded7687f7 | ||
|
|
42119425af | ||
|
|
93c7e1c44a | ||
|
|
381fd84839 | ||
|
|
67bab72783 | ||
|
|
b9c084a442 | ||
|
|
a77f345596 | ||
|
|
6a2f487ed5 | ||
|
|
616cf9029d | ||
|
|
3f21b4172b | ||
|
|
62f6a11522 | ||
|
|
ae1dce455d | ||
|
|
7b5dc52620 | ||
|
|
0bf50de54d | ||
|
|
1eec470902 | ||
|
|
867dc69479 | ||
|
|
9792f4ebc1 | ||
|
|
a44fc41caa | ||
|
|
1927dbf0ad | ||
|
|
7052674f32 | ||
|
|
9a487f3bb6 | ||
|
|
d6242c350d | ||
|
|
4cc4aceb41 | ||
|
|
dd8f4d6201 | ||
|
|
f44dcdf124 | ||
|
|
65e8f2317e | ||
|
|
1c4f43db7c | ||
|
|
24b9df6fbd | ||
|
|
c46891ecb0 | ||
|
|
e723601f6b | ||
|
|
77545df174 | ||
|
|
7e136b17a5 | ||
|
|
a970a06f57 | ||
|
|
95dd78d4aa | ||
|
|
eeaf33be20 | ||
|
|
7ed0cd26fe | ||
|
|
d26403c800 | ||
|
|
5ed6e3a6a3 | ||
|
|
7e83405370 | ||
|
|
a5de31da2b | ||
|
|
39c06005ed | ||
|
|
9479160044 | ||
|
|
46ec7369e5 | ||
|
|
424e7d742b | ||
|
|
44036399b9 | ||
|
|
e4394da467 | ||
|
|
b837ada3f8 | ||
|
|
a623de89aa | ||
|
|
2768407907 | ||
|
|
64ed35d2b1 | ||
|
|
b92f4dfa02 | ||
|
|
e4f02b7a51 | ||
|
|
24c3f45018 | ||
|
|
fcf55eb581 | ||
|
|
9ade7add32 | ||
|
|
d16dfcb4b4 | ||
|
|
37c58a5d32 | ||
|
|
e6cdde4f70 | ||
|
|
93c0adecbd | ||
|
|
33d44a5181 | ||
|
|
80e6fa7e7a | ||
|
|
b8eed48112 | ||
|
|
8d563dcfed | ||
|
|
1cf36e5549 | ||
|
|
1357a0628f | ||
|
|
c4fe0680f3 | ||
|
|
16694aa932 | ||
|
|
ea59ad2953 | ||
|
|
cf2f684e80 | ||
|
|
01522eda43 | ||
|
|
a200ee1310 | ||
|
|
579a07baa2 | ||
|
|
a82dd88b1f | ||
|
|
36ba98920a | ||
|
|
47cc0c34eb | ||
|
|
53eaca8724 | ||
|
|
8a2814b391 | ||
|
|
345c8ab217 | ||
|
|
7c010895e0 | ||
|
|
e716cfd19b | ||
|
|
461a6c8be5 | ||
|
|
3360f1a3f6 | ||
|
|
8b939e8a02 | ||
|
|
3a8998081c | ||
|
|
9b986a7b15 | ||
|
|
69ab395798 | ||
|
|
7aa3c33348 | ||
|
|
734328dd1f | ||
|
|
7d6c30e733 | ||
|
|
3e682dfe50 | ||
|
|
50efda3858 | ||
|
|
ca1b9e2bdb | ||
|
|
aa3a91b9d9 | ||
|
|
7117608445 | ||
|
|
6de640f53f | ||
|
|
75627ea40f | ||
|
|
0fa04d7d9a | ||
|
|
1e76ebe075 | ||
|
|
965da2fee3 | ||
|
|
b03f59ace3 | ||
|
|
79c7244b24 | ||
|
|
0843be3f4e | ||
|
|
6911f368e5 | ||
|
|
42167ea738 | ||
|
|
1c57d3506a | ||
|
|
8adc6fae4c | ||
|
|
e9746577ba | ||
|
|
9f68730622 | ||
|
|
f019b67b0c | ||
|
|
0f650c0572 | ||
|
|
dfc77ea0d0 | ||
|
|
67f514524f | ||
|
|
d97c6a0872 | ||
|
|
e7ad62760f | ||
|
|
fd34ea8124 | ||
|
|
5db94bf5b8 | ||
|
|
796d6d79d4 | ||
|
|
03aafafe1c | ||
|
|
3f303abc19 | ||
|
|
85d6958025 | ||
|
|
669bd7827b | ||
|
|
2b48f0c6a9 | ||
|
|
2c44d38906 | ||
|
|
750726ff6a | ||
|
|
dafab492fe | ||
|
|
6419c8dc3a | ||
|
|
3bb5ce5924 | ||
|
|
882c2749ab | ||
|
|
26b0fae0fb | ||
|
|
fe921fd5b1 | ||
|
|
88e1877742 | ||
|
|
74758c7762 | ||
|
|
2000534e37 | ||
|
|
390388fe83 | ||
|
|
b9e3ccd0c1 | ||
|
|
23e0a44e54 | ||
|
|
e788f8767a | ||
|
|
d09e88f138 | ||
|
|
6e1eee48f0 | ||
|
|
99c8949861 | ||
|
|
ce44ee8514 | ||
|
|
490b45d476 | ||
|
|
e403304e46 | ||
|
|
2bc00bb7f7 | ||
|
|
2785b1d09a | ||
|
|
1d703f8c87 | ||
|
|
93748a917f | ||
|
|
46d5e1d96c | ||
|
|
1e791f9601 | ||
|
|
ea2e8c7cc9 | ||
|
|
9ca160f1da | ||
|
|
1b0843d12e | ||
|
|
b1c4ceb40a | ||
|
|
ff626a3609 | ||
|
|
0862aecfc9 | ||
|
|
e119672336 | ||
|
|
71f6f53111 | ||
|
|
8cf757c080 | ||
|
|
6e0f67b19c | ||
|
|
fa2400871a | ||
|
|
7ef3f1895e | ||
|
|
637911e6b7 | ||
|
|
1b0d7c2463 | ||
|
|
dfb793247c | ||
|
|
a730ca38f2 | ||
|
|
e59a955e6b | ||
|
|
0fbe5f6c86 | ||
|
|
312b04acc9 | ||
|
|
8c20b7985b | ||
|
|
c10a82842e | ||
|
|
7a608bb2b2 | ||
|
|
a8493b2778 | ||
|
|
02a6dd71d4 | ||
|
|
bb5e1e6d71 | ||
|
|
bfbb04dc93 | ||
|
|
8f1b92add5 | ||
|
|
cab98eff3d | ||
|
|
4877d41ff2 | ||
|
|
01c5c03653 | ||
|
|
be31f265b8 | ||
|
|
6ef9721b7d | ||
|
|
13a50c990b | ||
|
|
67e2f2366d | ||
|
|
0947c2fe6f | ||
|
|
7c8d5658f3 | ||
|
|
66cf4639e6 | ||
|
|
8b370da5b7 | ||
|
|
a6d49fc926 | ||
|
|
8aedd1cd95 | ||
|
|
647bd8193b | ||
|
|
b6349fb3a7 | ||
|
|
675c10f7f7 | ||
|
|
5ab5609397 | ||
|
|
6de4bc14af | ||
|
|
8579536eff | ||
|
|
36ec19e25e | ||
|
|
b6b54e04b8 | ||
|
|
5e4ac06493 | ||
|
|
1f05e18ce4 | ||
|
|
cd059542c6 | ||
|
|
b207c18abe | ||
|
|
6b7d3775e4 | ||
|
|
7e1d942e0a | ||
|
|
5cb49daaba | ||
|
|
4e132dcc28 | ||
|
|
288ef8f368 | ||
|
|
66206adbe0 | ||
|
|
c6529ed8a3 | ||
|
|
6ddb44afb6 | ||
|
|
7f84bbe43c | ||
|
|
51b8eece7e | ||
|
|
c891c227a6 | ||
|
|
03457ef138 | ||
|
|
36fb117f89 | ||
|
|
605b52df0e | ||
|
|
45d3a15c68 | ||
|
|
78d7ba69df | ||
|
|
c72f7cddc8 | ||
|
|
e7c3242765 | ||
|
|
35d60ecaa0 | ||
|
|
bcf40c0b76 | ||
|
|
922601eab7 | ||
|
|
bc4267ea1d | ||
|
|
f65d3213fe | ||
|
|
bf2107c01b | ||
|
|
33c738873f | ||
|
|
5bf724b2a2 | ||
|
|
769ce0ade9 | ||
|
|
6f01eafd30 | ||
|
|
5ec27932b0 | ||
|
|
d79ff6e6d5 | ||
|
|
b0e7d3af2c | ||
|
|
f6966efff7 | ||
|
|
79e71a6978 | ||
|
|
5beed3e824 | ||
|
|
c6ea9daa3a | ||
|
|
95440951e5 | ||
|
|
7a45da2293 | ||
|
|
8ae8390339 | ||
|
|
27bc1f4a66 | ||
|
|
b887052d65 | ||
|
|
90bd2fc54c | ||
|
|
e433f7173f | ||
|
|
a1ce605719 | ||
|
|
665cbddcf5 | ||
|
|
6c0a39099f | ||
|
|
b2576d0b35 | ||
|
|
7107ea4aff | ||
|
|
2c43a71850 | ||
|
|
16b93bc490 | ||
|
|
d56a77acdf | ||
|
|
5da1dd1756 | ||
|
|
66e57e8d8f | ||
|
|
db1d7ae549 | ||
|
|
3d85c4085b | ||
|
|
da23b2cc51 | ||
|
|
38926a5b4f | ||
|
|
b39003d519 | ||
|
|
c79ea6ffd2 | ||
|
|
a7f1ead7fe | ||
|
|
3c56087432 | ||
|
|
1e98dea091 | ||
|
|
d239d06c3e | ||
|
|
339393e904 | ||
|
|
40cfb2593c | ||
|
|
346b418908 | ||
|
|
03c2850c38 | ||
|
|
1c29a08679 | ||
|
|
db89ac7743 | ||
|
|
412333cfc4 | ||
|
|
033b0b6ebf | ||
|
|
ab02ce5601 | ||
|
|
d46777595d | ||
|
|
669ced862e | ||
|
|
ba66cc02b7 | ||
|
|
1e840250b9 | ||
|
|
2b615a51fb | ||
|
|
f0c92be5f2 | ||
|
|
20206d6e14 | ||
|
|
7c6e098014 | ||
|
|
1e0f1f329f | ||
|
|
b2ebeafed5 | ||
|
|
9492975a74 | ||
|
|
e194df455b | ||
|
|
1e01139028 | ||
|
|
1141c6f7a5 | ||
|
|
5a1951b495 | ||
|
|
3b3f94124a | ||
|
|
bb56a55143 | ||
|
|
853a12b0dd | ||
|
|
840641681e | ||
|
|
022951f406 | ||
|
|
b6c9df62eb | ||
|
|
27a14a6e3b | ||
|
|
45e3620634 | ||
|
|
d770d6aac7 | ||
|
|
37d60252fd | ||
|
|
1cdcb48c1f | ||
|
|
f9920a0b6b | ||
|
|
00f524db16 | ||
|
|
51401e333f | ||
|
|
7692c29a81 | ||
|
|
d7c755235e | ||
|
|
8589efdf6e | ||
|
|
02b83f02d6 | ||
|
|
e4e7679414 | ||
|
|
abdf51d045 | ||
|
|
5fd21c8393 | ||
|
|
e7e03697d6 | ||
|
|
9e7a76bd97 | ||
|
|
d4090d15be | ||
|
|
973dca83a2 | ||
|
|
c7f51e815c | ||
|
|
1c302a7ac1 | ||
|
|
5d29efe1d5 | ||
|
|
b066df4efc | ||
|
|
fa6b71afae | ||
|
|
3d0c064d41 | ||
|
|
50755ead18 | ||
|
|
79f7fa32ab | ||
|
|
703ab710e9 | ||
|
|
bc782ba9ee | ||
|
|
f2eef64d84 | ||
|
|
abb3a7a3a9 | ||
|
|
ef61e020b6 | ||
|
|
66c87f37e6 | ||
|
|
56caab19f1 | ||
|
|
27eb5f72f7 | ||
|
|
23a80a60b9 | ||
|
|
0520cbd538 | ||
|
|
c79ecbb92e | ||
|
|
08651d28ef | ||
|
|
3060d186a1 | ||
|
|
0509d7105e | ||
|
|
b6ab7dc8a7 | ||
|
|
822ca65349 | ||
|
|
4b4a2b46c1 | ||
|
|
13b020732f | ||
|
|
fb9ca7f5f3 | ||
|
|
e21d37b20f | ||
|
|
161614f6c9 | ||
|
|
16472e1de8 | ||
|
|
d81e47204c | ||
|
|
7ec93cd91e | ||
|
|
af516991a9 | ||
|
|
2cac88cf7c | ||
|
|
1546a85195 | ||
|
|
9eba3e4dc4 | ||
|
|
b568e2ecf1 | ||
|
|
9dce119530 | ||
|
|
24f9b134cd | ||
|
|
c3e6bdd73d | ||
|
|
80e11c1492 | ||
|
|
bdebb4972e | ||
|
|
9b5f9e00d9 | ||
|
|
57167b8a10 | ||
|
|
98889a0baf | ||
|
|
0c535bb2d8 | ||
|
|
f4c34a3102 | ||
|
|
1f4b4d30ee | ||
|
|
1b1dc91c72 | ||
|
|
5a6a76bd63 | ||
|
|
e5263f7719 | ||
|
|
267229f929 | ||
|
|
79ee8b09d6 | ||
|
|
7824693254 | ||
|
|
0b2319fbaa | ||
|
|
dea3844090 | ||
|
|
be2c5759f2 | ||
|
|
c8b14d7822 | ||
|
|
1b54016035 | ||
|
|
50fe370585 | ||
|
|
e423ad168a | ||
|
|
1d7670830c | ||
|
|
d05dbfd5fb | ||
|
|
7e3f1f1ffa | ||
|
|
882ed1871a | ||
|
|
7c5c7d5632 | ||
|
|
240c0da577 | ||
|
|
7472ef01a8 | ||
|
|
9907095a2d | ||
|
|
658a527d93 | ||
|
|
3d25f3dd62 | ||
|
|
6290e983e9 | ||
|
|
10ae92ec41 | ||
|
|
2c25286155 | ||
|
|
656ede98b6 | ||
|
|
5fb0118f60 | ||
|
|
27e47b2b4b | ||
|
|
f02e3358aa | ||
|
|
7caae3171c | ||
|
|
0cded599b1 | ||
|
|
4e48240158 | ||
|
|
b488e24798 | ||
|
|
7e7d87c147 | ||
|
|
8b6389cf3a | ||
|
|
8a2725b48b | ||
|
|
a7e8fe12f4 | ||
|
|
67a5473e27 | ||
|
|
e16fba8eab | ||
|
|
cdfbafc789 | ||
|
|
c318f5d57b | ||
|
|
556ab85b0d | ||
|
|
20018aed27 | ||
|
|
e2facc87f0 | ||
|
|
07e951039c | ||
|
|
386d9ebc52 | ||
|
|
d8e058a416 | ||
|
|
f5d9c6076c | ||
|
|
e870855602 | ||
|
|
a3a6b4618b | ||
|
|
3ecae3e16b | ||
|
|
111e8d38dc | ||
|
|
d5fa36b11a | ||
|
|
a35c94cf03 | ||
|
|
b2eff46c38 | ||
|
|
07c0187423 | ||
|
|
0074078539 | ||
|
|
d1641ac0e8 | ||
|
|
13d3489cde | ||
|
|
30efe6bd0a | ||
|
|
5d67c71791 | ||
|
|
c8d9887070 | ||
|
|
8268592e8e | ||
|
|
3b63c5c3f9 | ||
|
|
d3918df4e3 | ||
|
|
7e586da521 | ||
|
|
2f5407736c | ||
|
|
b29a98e680 | ||
|
|
239a996846 | ||
|
|
421ecf527d | ||
|
|
c60e55832e | ||
|
|
b1f9568f8a | ||
|
|
dd1adb433b | ||
|
|
5a4bcfbac5 | ||
|
|
179642fa8f | ||
|
|
33111c4f31 | ||
|
|
03721e8090 | ||
|
|
0c025e0497 | ||
|
|
8cec40f672 | ||
|
|
4f7044bffa | ||
|
|
2c6c857e55 | ||
|
|
ede8c4ebad | ||
|
|
88dc85c401 | ||
|
|
7a94f78d78 | ||
|
|
6cacf6b2c5 | ||
|
|
69d198117e | ||
|
|
eb035c1023 | ||
|
|
6dabc7a331 | ||
|
|
58fe29e526 | ||
|
|
20688d6395 | ||
|
|
044dd7fea9 | ||
|
|
2c1f5081f2 | ||
|
|
9243e90e90 | ||
|
|
0dc7813c40 | ||
|
|
847bedb65c | ||
|
|
8e6e0cf673 | ||
|
|
0da3045c73 | ||
|
|
d8accce9e7 | ||
|
|
dbfd27301c | ||
|
|
5ef5d8c343 | ||
|
|
6ff8282f9f | ||
|
|
e3930e0588 | ||
|
|
129db0ad21 | ||
|
|
13a9ed902c | ||
|
|
4e9435418e | ||
|
|
9d990644dd | ||
|
|
c733cab39e | ||
|
|
5a004eeed4 | ||
|
|
012ce933d3 | ||
|
|
9a2c6bc1ff | ||
|
|
f0877e192d | ||
|
|
a04175a106 | ||
|
|
0cdd4cd6ed | ||
|
|
c64d2a4fa4 | ||
|
|
0b4b5a0b56 | ||
|
|
42ff4e877a | ||
|
|
96f206e1a0 | ||
|
|
d2bce7a1e2 | ||
|
|
f1f64eaba1 | ||
|
|
817eaf1bb2 | ||
|
|
16de3fdb97 | ||
|
|
2452f09714 | ||
|
|
b02c1311ff | ||
|
|
21c35f7744 | ||
|
|
bd2f22e9d7 | ||
|
|
d27776fd26 | ||
|
|
416ce15d1d | ||
|
|
9f9abc6d3e | ||
|
|
995f042fc8 | ||
|
|
e905a50ed0 | ||
|
|
5e036992b0 | ||
|
|
cb81a87200 | ||
|
|
fad2ba23b7 | ||
|
|
e093c0820d | ||
|
|
a65c729906 | ||
|
|
1b7fc89d8b | ||
|
|
246b8512e4 | ||
|
|
4209f0e86d | ||
|
|
653f57ea5d | ||
|
|
1766111e1d | ||
|
|
8caa08843e | ||
|
|
a08ae6f977 | ||
|
|
db246b38f2 | ||
|
|
1119d47a80 | ||
|
|
d4ddfad838 | ||
|
|
e2d0799d17 | ||
|
|
f68f31c80f | ||
|
|
b7c8e72ce2 | ||
|
|
9f5b286a45 | ||
|
|
1de204ff02 | ||
|
|
cc4d364f75 | ||
|
|
cd629065b8 | ||
|
|
46dfd9c44e | ||
|
|
edc797035f | ||
|
|
c1eb7d70b7 | ||
|
|
921636ea69 | ||
|
|
40c2262f47 | ||
|
|
b91e8fd0a1 | ||
|
|
ad7ca59532 | ||
|
|
802c16c0df | ||
|
|
efbb0e1b1c | ||
|
|
607f6e9b6c | ||
|
|
72b775e179 | ||
|
|
491f312036 | ||
|
|
31221062dd | ||
|
|
73e6fa24a8 | ||
|
|
b335fd30d1 | ||
|
|
37395003cb | ||
|
|
39b360b132 | ||
|
|
34b16c2da9 | ||
|
|
525108bf26 | ||
|
|
7563bdf900 | ||
|
|
64785be947 | ||
|
|
a714e9b985 | ||
|
|
bc69a8768c | ||
|
|
7d67761ed8 | ||
|
|
3d9b508335 | ||
|
|
78f0c8823d | ||
|
|
145a3c44b7 | ||
|
|
2063f51289 | ||
|
|
da2bfd8fc9 | ||
|
|
eedcd188c3 | ||
|
|
8134ec84c6 | ||
|
|
e4a3cc5290 | ||
|
|
a3a58a25ef |
@@ -6,3 +6,7 @@ indent_size = 2
|
|||||||
charset = utf-8
|
charset = utf-8
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.swift]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||||
|
- type: dropdown
|
||||||
|
id: confirm-check
|
||||||
|
attributes:
|
||||||
|
label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22)
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- 'Yes'
|
||||||
|
- 'No'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: what-happened
|
id: what-happened
|
||||||
attributes:
|
attributes:
|
||||||
@@ -44,7 +54,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Phone Model
|
label: Phone Model
|
||||||
description: What kind of phone are you using?
|
description: What kind of phone are you using?
|
||||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
@@ -62,10 +72,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- Android App - 0.9.76
|
- 'Android App - 0.13.0'
|
||||||
- iOS App - 0.9.76
|
- 'iOS App - 0.13.0'
|
||||||
- Android App - 0.9.75
|
- 'Android App - 0.12.0'
|
||||||
- iOS App - 0.9.75
|
- 'iOS App - 0.12.0'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
@@ -74,10 +84,10 @@ body:
|
|||||||
label: Installation Source
|
label: Installation Source
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- Google Play Store
|
- 'Google Play Store'
|
||||||
- Testflight
|
- 'Testflight'
|
||||||
- SideStore
|
- 'SideStore'
|
||||||
- Other (List in "Additional Notes")
|
- 'Other (List in "Additional Notes")'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/HQgCbd6E75
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
@@ -43,10 +43,10 @@ body:
|
|||||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- Android App - 0.9.76
|
- 'Android App - 0.13.0'
|
||||||
- iOS App - 0.9.76
|
- 'iOS App - 0.13.0'
|
||||||
- Android App - 0.9.75
|
- 'Android App - 0.12.0'
|
||||||
- iOS App - 0.9.75
|
- 'iOS App - 0.12.0'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||||
|
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||||
|
|
||||||
|
If you do not follow this template, the PR may be closed without review.
|
||||||
|
|
||||||
|
Please ensure all checks pass.
|
||||||
|
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Brief summary
|
||||||
|
|
||||||
|
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||||
|
|
||||||
|
## Which issue is fixed?
|
||||||
|
|
||||||
|
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||||
|
|
||||||
|
## Pull Request Type
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Does this affect only Android, only iOS, or both?
|
||||||
|
Does this change the frontend or the backend of the apps?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## In-depth Description
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe your solution in more depth.
|
||||||
|
How does it work? Why is this the best solution?
|
||||||
|
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## How have you tested this?
|
||||||
|
|
||||||
|
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<!-- If your PR includes any changes to the front-end, please include screenshots or a
|
||||||
|
short video from before and after your changes. -->
|
||||||
@@ -13,41 +13,41 @@ jobs:
|
|||||||
main:
|
main:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 20.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v2
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: 'temurin'
|
||||||
java-version: 17
|
java-version: 21
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: build Nuxt project
|
- name: build Nuxt project
|
||||||
run: npm run generate
|
run: npm run generate
|
||||||
|
|
||||||
- name: copy to Android project
|
- name: copy to Android project
|
||||||
run: npx cap sync
|
run: npx cap sync
|
||||||
|
|
||||||
- name: build Android app
|
- name: build Android app
|
||||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||||
|
|
||||||
- name: rename apk
|
- name: rename apk
|
||||||
working-directory: android/app/build/outputs/apk/debug/
|
working-directory: android/app/build/outputs/apk/debug/
|
||||||
run: |
|
run: |
|
||||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||||
name="audiobookshelf-${build}.apk"
|
name="audiobookshelf-${build}.apk"
|
||||||
mv -v app-debug.apk "${name}"
|
mv -v app-debug.apk "${name}"
|
||||||
|
|
||||||
- name: upload app
|
- name: upload app
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: audiobookshelf-apk
|
name: audiobookshelf-apk
|
||||||
path: android/app/build/outputs/apk/debug/*.apk
|
path: android/app/build/outputs/apk/debug/*.apk
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
name: Close Issues not using a template
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close_issue:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check issue headings
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueBody = context.payload.issue.body || "";
|
||||||
|
|
||||||
|
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||||
|
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||||
|
const headings = [...issueBody.matchAll(headingRegex)];
|
||||||
|
|
||||||
|
if (headings.length < 3) {
|
||||||
|
// Post a comment
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the issue
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
state: "closed"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,57 +12,57 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 20.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v2
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: 'temurin'
|
||||||
java-version: 17
|
java-version: 21
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: build Nuxt project
|
- name: build Nuxt project
|
||||||
run: npm run generate
|
run: npm run generate
|
||||||
|
|
||||||
- name: copy to Android project
|
- name: copy to Android project
|
||||||
run: npx cap sync
|
run: npx cap sync
|
||||||
|
|
||||||
- name: build Android app
|
- name: build Android app
|
||||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||||
|
|
||||||
- name: rename apk
|
- name: rename apk
|
||||||
working-directory: android/app/build/outputs/apk/debug/
|
working-directory: android/app/build/outputs/apk/debug/
|
||||||
run: |
|
run: |
|
||||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||||
name="audiobookshelf-${build}.apk"
|
name="audiobookshelf-${build}.apk"
|
||||||
mv -v app-debug.apk "${name}"
|
mv -v app-debug.apk "${name}"
|
||||||
|
|
||||||
- name: prepare test page ressources
|
- name: prepare test page ressources
|
||||||
run: |
|
run: |
|
||||||
mkdir ghpages
|
mkdir ghpages
|
||||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||||
cp static/Logo.png ghpages/logo.png
|
cp static/Logo.png ghpages/logo.png
|
||||||
cp .github/testing-page-template.html ghpages/index.html
|
cp .github/testing-page-template.html ghpages/index.html
|
||||||
|
|
||||||
- name: build test page
|
- name: build test page
|
||||||
working-directory: ghpages
|
working-directory: ghpages
|
||||||
run: |
|
run: |
|
||||||
sed -i "s/__DATE__/$(date)/g" index.html
|
sed -i "s/__DATE__/$(date)/g" index.html
|
||||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||||
|
|
||||||
- name: upload test page artifact
|
- name: upload test page artifact
|
||||||
uses: actions/upload-pages-artifact@v1
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: ./ghpages
|
path: ./ghpages
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -79,4 +79,4 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: deploy to GitHub Pages
|
- name: deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v1
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"java.project.root": "android",
|
||||||
|
"java.import.gradle.enabled": true,
|
||||||
|
"java.import.gradle.wrapper.enabled": true,
|
||||||
|
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
|
||||||
|
}
|
||||||
@@ -18,51 +18,54 @@ kotlin {
|
|||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.audiobookshelf.app'
|
namespace 'com.audiobookshelf.app'
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs = ['-Xjvm-default=all']
|
freeCompilerArgs = ['-Xjvm-default=all']
|
||||||
}
|
}
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 108
|
versionCode 118
|
||||||
versionName "0.9.77-beta"
|
versionName "0.14.0-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
]
|
]
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
}
|
|
||||||
}
|
}
|
||||||
buildTypes {
|
}
|
||||||
debug {
|
buildTypes {
|
||||||
applicationIdSuffix ".debug"
|
debug {
|
||||||
versionNameSuffix "-debug"
|
applicationIdSuffix ".debug"
|
||||||
minifyEnabled false
|
versionNameSuffix "-debug"
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
minifyEnabled false
|
||||||
}
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
flatDir{
|
flatDir {
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,19 +81,19 @@ configurations.configureEach {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':byteowls-capacitor-filesharer')
|
implementation project(':webnativellc-capacitor-filesharer')
|
||||||
|
implementation project(':capacitor-community-keep-awake')
|
||||||
implementation project(':capacitor-community-volume-buttons')
|
implementation project(':capacitor-community-volume-buttons')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-browser')
|
implementation project(':capacitor-browser')
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -36,6 +38,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/media_player_widget_info" />
|
android:resource="@xml/media_player_widget_info" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.samsung.android.appwidget.provider"
|
||||||
|
android:resource="@xml/samsung_cover_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Used by Android Auto -->
|
<!-- Used by Android Auto -->
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
@@ -94,6 +100,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".services.DownloadService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
"appId": "com.audiobookshelf.app",
|
"appId": "com.audiobookshelf.app",
|
||||||
"appName": "audiobookshelf-app",
|
"appName": "audiobookshelf-app",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false,
|
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"androidScheme": "http"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"pkg": "@byteowls/capacitor-filesharer",
|
"pkg": "@webnativellc/capacitor-filesharer",
|
||||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor-community/keep-awake",
|
||||||
|
"classpath": "com.getcapacitor.community.keepawake.KeepAwakePlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor-community/volume-buttons",
|
"pkg": "@capacitor-community/volume-buttons",
|
||||||
"classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin"
|
"classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin"
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.webkit.WebView
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
@@ -18,6 +23,7 @@ import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
|||||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
|
|
||||||
|
|
||||||
@@ -34,40 +40,75 @@ class MainActivity : BridgeActivity() {
|
|||||||
val storage = SimpleStorage(this)
|
val storage = SimpleStorage(this)
|
||||||
|
|
||||||
val REQUEST_PERMISSIONS = 1
|
val REQUEST_PERMISSIONS = 1
|
||||||
var PERMISSIONS_ALL = arrayOf(
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
)
|
|
||||||
|
|
||||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// TODO: Optimize using strict mode logs
|
|
||||||
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
|
||||||
// .detectDiskReads()
|
|
||||||
// .detectDiskWrites().detectAll()
|
|
||||||
// .detectNetwork() // or .detectAll() for all detectable problems
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
// StrictMode.setVmPolicy(VmPolicy.Builder()
|
|
||||||
// .detectLeakedSqlLiteObjects()
|
|
||||||
// .detectLeakedClosableObjects()
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
DbManager.initialize(applicationContext)
|
DbManager.initialize(applicationContext)
|
||||||
|
|
||||||
registerPlugin(AbsAudioPlayer::class.java)
|
registerPlugin(AbsAudioPlayer::class.java)
|
||||||
registerPlugin(AbsDownloader::class.java)
|
registerPlugin(AbsDownloader::class.java)
|
||||||
registerPlugin(AbsFileSystem::class.java)
|
registerPlugin(AbsFileSystem::class.java)
|
||||||
registerPlugin(AbsDatabase::class.java)
|
registerPlugin(AbsDatabase::class.java)
|
||||||
|
registerPlugin(AbsLogger::class.java)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.d(tag, "onCreate")
|
Log.d(tag, "onCreate")
|
||||||
|
|
||||||
|
// Update the margins to handle edge-to-edge enforced in SDK 35
|
||||||
|
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge
|
||||||
|
val webView: WebView = findViewById(R.id.webview)
|
||||||
|
webView.setOnApplyWindowInsetsListener { v, insets ->
|
||||||
|
val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val sysInsets = insets.getInsets(WindowInsets.Type.systemBars())
|
||||||
|
Log.d(tag, "safe sysInsets: $sysInsets")
|
||||||
|
arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
|
||||||
|
} else {
|
||||||
|
arrayOf(
|
||||||
|
insets.systemWindowInsetLeft,
|
||||||
|
insets.systemWindowInsetTop,
|
||||||
|
insets.systemWindowInsetRight,
|
||||||
|
insets.systemWindowInsetBottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject as CSS variables
|
||||||
|
// NOTE: Possibly able to use in the future to support edge-to-edge better.
|
||||||
|
val js = """
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
|
||||||
|
""".trimIndent()
|
||||||
|
webView.evaluateJavascript(js, null)
|
||||||
|
|
||||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
// Set margins
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
ActivityCompat.requestPermissions(this,
|
leftMargin = left
|
||||||
PERMISSIONS_ALL,
|
bottomMargin = bottom
|
||||||
REQUEST_PERMISSIONS)
|
rightMargin = right
|
||||||
|
topMargin = top
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
} else {
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestNeededPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNeededPermissions() {
|
||||||
|
val needed = mutableListOf<String>()
|
||||||
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
needed.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
if (needed.isNotEmpty()) {
|
||||||
|
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeStream(
|
|
||||||
val index:Int,
|
|
||||||
val codec_name:String,
|
|
||||||
val codec_long_name:String,
|
|
||||||
val channels:Int,
|
|
||||||
val channel_layout:String,
|
|
||||||
val duration:Double,
|
|
||||||
val bit_rate:Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeChapterTags(
|
|
||||||
val title:String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeChapter(
|
|
||||||
val id:Int,
|
|
||||||
val start:Long,
|
|
||||||
val end:Long,
|
|
||||||
val tags:AudioProbeChapterTags?
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun getBookChapter():BookChapter {
|
|
||||||
val startS = start / 1000.0
|
|
||||||
val endS = end / 1000.0
|
|
||||||
val title = tags?.title ?: "Chapter $id"
|
|
||||||
return BookChapter(id, startS, endS, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeFormatTags(
|
|
||||||
val artist:String?,
|
|
||||||
val album:String?,
|
|
||||||
val comment:String?,
|
|
||||||
val date:String?,
|
|
||||||
val genre:String?,
|
|
||||||
val title:String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeFormat(
|
|
||||||
val filename:String,
|
|
||||||
val format_name:String,
|
|
||||||
val duration:Double,
|
|
||||||
val size:Long,
|
|
||||||
val bit_rate:Double,
|
|
||||||
val tags:AudioProbeFormatTags?
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class AudioProbeResult (
|
|
||||||
val streams:MutableList<AudioProbeStream>,
|
|
||||||
val chapters:MutableList<AudioProbeChapter>,
|
|
||||||
val format:AudioProbeFormat) {
|
|
||||||
|
|
||||||
val duration get() = format.duration
|
|
||||||
val size get() = format.size
|
|
||||||
val title get() = format.tags?.title ?: format.filename.split("/").last()
|
|
||||||
val artist get() = format.tags?.artist ?: ""
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getBookChapters(): List<BookChapter> {
|
|
||||||
if (chapters.isEmpty()) return mutableListOf()
|
|
||||||
return chapters.map { it.getBookChapter() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,30 +5,34 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class AudioTrack(
|
data class AudioTrack(
|
||||||
var index:Int,
|
var index: Int,
|
||||||
var startOffset:Double,
|
var startOffset: Double,
|
||||||
var duration:Double,
|
var duration: Double,
|
||||||
var title:String,
|
var title: String,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var mimeType:String,
|
var mimeType: String?,
|
||||||
var metadata:FileMetadata?,
|
var metadata: FileMetadata?,
|
||||||
var isLocal:Boolean,
|
var isLocal: Boolean,
|
||||||
var localFileId:String?,
|
var localFileId: String?,
|
||||||
var audioProbeResult:AudioProbeResult?,
|
// TODO: This should no longer be necessary
|
||||||
var serverIndex:Int? // Need to know if server track index is different
|
var serverIndex: Int? // Need to know if server track index is different
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
val startOffsetMs
|
||||||
|
get() = (startOffset * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val durationMs get() = (duration * 1000L).toLong()
|
val durationMs
|
||||||
|
get() = (duration * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val endOffsetMs get() = startOffsetMs + durationMs
|
val endOffsetMs
|
||||||
|
get() = startOffsetMs + durationMs
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val relPath get() = metadata?.relPath ?: ""
|
val relPath
|
||||||
|
get() = metadata?.relPath ?: ""
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getBookChapter():BookChapter {
|
fun getBookChapter(): BookChapter {
|
||||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
return BookChapter(index + 1, startOffset, startOffset + duration, title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class CollapsedSeries(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String?,
|
||||||
|
var name:String,
|
||||||
|
//var nameIgnorePrefix:String,
|
||||||
|
var sequence:String?,
|
||||||
|
var libraryItemIds:MutableList<String>
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
@get:JsonIgnore
|
||||||
|
val numBooks get() = libraryItemIds.size
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__SERIE__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("${numBooks} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.icu.text.DateFormat
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.fasterxml.jackson.annotation.*
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
// This auto-detects whether it is a Book or Podcast
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
@@ -25,7 +28,8 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||||||
open fun removeAudioTrack(localFileId:String) { }
|
open fun removeAudioTrack(localFileId:String) { }
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun checkHasTracks():Boolean { return false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -93,6 +97,11 @@ class Podcast(
|
|||||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun checkHasTracks():Boolean {
|
||||||
|
return (episodes?.size ?: numEpisodes ?: 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val localEpisodeId = "local_ep_" + episode.id
|
val localEpisodeId = "local_ep_" + episode.id
|
||||||
@@ -182,6 +191,11 @@ class Book(
|
|||||||
override fun getLocalCopy(): Book {
|
override fun getLocalCopy(): Book {
|
||||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun checkHasTracks():Boolean {
|
||||||
|
return (tracks?.size ?: numTracks ?: 0) > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
||||||
@@ -214,7 +228,9 @@ class BookMetadata(
|
|||||||
var authorName:String?,
|
var authorName:String?,
|
||||||
var authorNameLF:String?,
|
var authorNameLF:String?,
|
||||||
var narratorName:String?,
|
var narratorName:String?,
|
||||||
var seriesName:String?
|
var seriesName:String?,
|
||||||
|
@JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY])
|
||||||
|
var series:List<SeriesType>?
|
||||||
) : MediaTypeMetadata(title, explicit) {
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||||
@@ -299,11 +315,18 @@ data class PodcastEpisode(
|
|||||||
|
|
||||||
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||||
val mediaId = localEpisodeId ?: id
|
val mediaId = localEpisodeId ?: id
|
||||||
|
var subtitle = libraryItemDescription.title
|
||||||
|
if (publishedAt !== null) {
|
||||||
|
val sdf = DateFormat.getDateInstance()
|
||||||
|
val publishedAtDT = Date(publishedAt!!)
|
||||||
|
subtitle = "${sdf.format(publishedAtDT)} / $subtitle"
|
||||||
|
}
|
||||||
|
|
||||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
.setIconUri(coverUri)
|
.setIconUri(coverUri)
|
||||||
.setSubtitle(libraryItemDescription.title)
|
.setSubtitle(subtitle)
|
||||||
.setExtras(extras)
|
.setExtras(extras)
|
||||||
|
|
||||||
libraryItemDescription.iconBitmap?.let {
|
libraryItemDescription.iconBitmap?.let {
|
||||||
@@ -342,18 +365,39 @@ data class Library(
|
|||||||
var name:String,
|
var name:String,
|
||||||
var folders:MutableList<Folder>,
|
var folders:MutableList<Folder>,
|
||||||
var icon:String,
|
var icon:String,
|
||||||
var mediaType:String
|
var mediaType:String,
|
||||||
|
var stats: LibraryStats?
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
fun getMediaMetadata(context: Context, targetType: String? = null): MediaMetadataCompat {
|
||||||
|
var mediaId = id
|
||||||
|
if (targetType !== null) {
|
||||||
|
mediaId = "__RECENTLY__$id"
|
||||||
|
}
|
||||||
return MediaMetadataCompat.Builder().apply {
|
return MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString())
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryStats(
|
||||||
|
var totalItems: Int,
|
||||||
|
var totalSize: Long,
|
||||||
|
var totalDuration: Double,
|
||||||
|
var numAudioFiles: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class SeriesType(
|
||||||
|
var id: String,
|
||||||
|
var name: String,
|
||||||
|
var sequence: String?
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class Folder(
|
data class Folder(
|
||||||
var id:String,
|
var id:String,
|
||||||
@@ -387,3 +431,84 @@ data class LibraryItemWithEpisode(
|
|||||||
var libraryItemWrapper:LibraryItemWrapper,
|
var libraryItemWrapper:LibraryItemWrapper,
|
||||||
var episode:PodcastEpisode
|
var episode:PodcastEpisode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultSeriesItemType(
|
||||||
|
var series: LibrarySeriesItem,
|
||||||
|
var books: List<LibraryItem>?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultLibraryItemType(
|
||||||
|
val libraryItem: LibraryItem
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultType(
|
||||||
|
var book:List<LibraryItemSearchResultLibraryItemType>?,
|
||||||
|
var podcast:List<LibraryItemSearchResultLibraryItemType>?,
|
||||||
|
var series:List<LibraryItemSearchResultSeriesItemType>?,
|
||||||
|
var authors:List<LibraryAuthorItem>?
|
||||||
|
)
|
||||||
|
|
||||||
|
// For personalized shelves
|
||||||
|
@JsonTypeInfo(
|
||||||
|
use=JsonTypeInfo.Id.NAME,
|
||||||
|
property = "type",
|
||||||
|
include = JsonTypeInfo.As.PROPERTY,
|
||||||
|
visible = true
|
||||||
|
)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast")
|
||||||
|
)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
sealed class LibraryShelfType(
|
||||||
|
open val id: String,
|
||||||
|
open val label: String,
|
||||||
|
open val total: Int,
|
||||||
|
open val type: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LibraryShelfBookEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfSeriesEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibrarySeriesItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfAuthorEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryAuthorItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfEpisodeEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfPodcastEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|||||||
@@ -1,67 +1,102 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
enum class LockOrientationSetting {
|
enum class LockOrientationSetting {
|
||||||
NONE, PORTRAIT, LANDSCAPE
|
NONE,
|
||||||
|
PORTRAIT,
|
||||||
|
LANDSCAPE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class HapticFeedbackSetting {
|
enum class HapticFeedbackSetting {
|
||||||
OFF, LIGHT, MEDIUM, HEAVY
|
OFF,
|
||||||
|
LIGHT,
|
||||||
|
MEDIUM,
|
||||||
|
HEAVY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ShakeSensitivitySetting {
|
enum class ShakeSensitivitySetting {
|
||||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
VERY_LOW,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH,
|
||||||
|
VERY_HIGH
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DownloadUsingCellularSetting {
|
enum class DownloadUsingCellularSetting {
|
||||||
ASK, ALWAYS, NEVER
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StreamingUsingCellularSetting {
|
enum class StreamingUsingCellularSetting {
|
||||||
ASK, ALWAYS, NEVER
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||||
|
ASC,
|
||||||
|
DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id: String,
|
||||||
var index:Int,
|
var index: Int,
|
||||||
var name:String,
|
var name: String,
|
||||||
var address:String,
|
var address: String,
|
||||||
var userId:String,
|
// version added after 0.9.81-beta
|
||||||
var username:String,
|
var version: String?,
|
||||||
var token:String,
|
var userId: String,
|
||||||
var customHeaders:Map<String, String>?
|
var username: String,
|
||||||
|
var token: String,
|
||||||
|
var customHeaders: Map<String, String>?
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFile(
|
data class LocalFile(
|
||||||
var id:String,
|
var id: String,
|
||||||
var filename:String?,
|
var filename: String?,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var basePath:String,
|
var basePath: String,
|
||||||
var absolutePath:String,
|
var absolutePath: String,
|
||||||
var simplePath:String,
|
var mimeType: String?,
|
||||||
var mimeType:String?,
|
var size: Long
|
||||||
var size:Long
|
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun isAudioFile():Boolean {
|
fun exists(ctx: Context): Boolean {
|
||||||
|
if (contentUrl.startsWith("content:")) {
|
||||||
|
return try {
|
||||||
|
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return File(absolutePath).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun isAudioFile(): Boolean {
|
||||||
if (mimeType == "application/octet-stream") return true
|
if (mimeType == "application/octet-stream") return true
|
||||||
if (mimeType == "video/mp4") return true
|
if (mimeType == "video/mp4") return true
|
||||||
return mimeType?.startsWith("audio") == true
|
return mimeType?.startsWith("audio") == true
|
||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun isEBookFile():Boolean {
|
fun isEBookFile(): Boolean {
|
||||||
return getEBookFormat() != null
|
return getEBookFormat() != null
|
||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getEBookFormat():String? {
|
fun getEBookFormat(): String? {
|
||||||
if (mimeType == "application/epub+zip") return "epub"
|
if (mimeType == "application/epub+zip") return "epub"
|
||||||
if (mimeType == "application/pdf") return "pdf"
|
if (mimeType == "application/pdf") return "pdf"
|
||||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||||
@@ -74,115 +109,127 @@ data class LocalFile(
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFolder(
|
data class LocalFolder(
|
||||||
var id:String,
|
var id: String,
|
||||||
var name:String,
|
var name: String,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var basePath:String,
|
var basePath: String,
|
||||||
var absolutePath:String,
|
var absolutePath: String,
|
||||||
var simplePath:String,
|
var storageType: String,
|
||||||
var storageType:String,
|
var mediaType: String
|
||||||
var mediaType:String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||||
JsonSubTypes.Type(LibraryItem::class),
|
open class LibraryItemWrapper(var id: String) {
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
|
||||||
)
|
|
||||||
open class LibraryItemWrapper(var id:String) {
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
open fun getMediaDescription(
|
||||||
|
progress: MediaProgressWrapper?,
|
||||||
|
ctx: Context
|
||||||
|
): MediaDescriptionCompat {
|
||||||
|
return MediaDescriptionCompat.Builder().build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceInfo(
|
data class DeviceInfo(
|
||||||
var deviceId:String,
|
var deviceId: String,
|
||||||
var manufacturer:String,
|
var manufacturer: String,
|
||||||
var model:String,
|
var model: String,
|
||||||
var sdkVersion:Int,
|
var sdkVersion: Int,
|
||||||
var clientVersion: String
|
var clientVersion: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class PlayItemRequestPayload(
|
data class PlayItemRequestPayload(
|
||||||
var mediaPlayer:String,
|
var mediaPlayer: String,
|
||||||
var forceDirectPlay:Boolean,
|
var forceDirectPlay: Boolean,
|
||||||
var forceTranscode:Boolean,
|
var forceTranscode: Boolean,
|
||||||
var deviceInfo:DeviceInfo
|
var deviceInfo: DeviceInfo
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class DeviceSettings(
|
data class DeviceSettings(
|
||||||
var disableAutoRewind:Boolean,
|
var disableAutoRewind: Boolean,
|
||||||
var enableAltView:Boolean,
|
var enableAltView: Boolean,
|
||||||
var allowSeekingOnMediaControls:Boolean,
|
var allowSeekingOnMediaControls: Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime: Int,
|
||||||
var jumpForwardTime:Int,
|
var jumpForwardTime: Int,
|
||||||
var enableMp3IndexSeeking:Boolean,
|
var enableMp3IndexSeeking: Boolean,
|
||||||
var disableShakeToResetSleepTimer:Boolean,
|
var disableShakeToResetSleepTimer: Boolean,
|
||||||
var shakeSensitivity: ShakeSensitivitySetting,
|
var shakeSensitivity: ShakeSensitivitySetting,
|
||||||
var lockOrientation: LockOrientationSetting,
|
var lockOrientation: LockOrientationSetting,
|
||||||
var hapticFeedback: HapticFeedbackSetting,
|
var hapticFeedback: HapticFeedbackSetting,
|
||||||
var autoSleepTimer: Boolean,
|
var autoSleepTimer: Boolean,
|
||||||
var autoSleepTimerStartTime: String,
|
var autoSleepTimerStartTime: String,
|
||||||
var autoSleepTimerEndTime: String,
|
var autoSleepTimerEndTime: String,
|
||||||
var autoSleepTimerAutoRewind: Boolean,
|
var autoSleepTimerAutoRewind: Boolean,
|
||||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
|
||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean,
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
var languageCode: String,
|
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
var languageCode: String,
|
||||||
var streamingUsingCellular: StreamingUsingCellularSetting
|
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||||
|
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||||
|
var androidAutoBrowseLimitForGrouping: Int,
|
||||||
|
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Static method to get default device settings
|
// Static method to get default device settings
|
||||||
fun default():DeviceSettings {
|
fun default(): DeviceSettings {
|
||||||
return DeviceSettings(
|
return DeviceSettings(
|
||||||
disableAutoRewind = false,
|
disableAutoRewind = false,
|
||||||
enableAltView = true,
|
enableAltView = true,
|
||||||
allowSeekingOnMediaControls = false,
|
allowSeekingOnMediaControls = false,
|
||||||
jumpBackwardsTime = 10,
|
jumpBackwardsTime = 10,
|
||||||
jumpForwardTime = 10,
|
jumpForwardTime = 10,
|
||||||
enableMp3IndexSeeking = false,
|
enableMp3IndexSeeking = false,
|
||||||
disableShakeToResetSleepTimer = false,
|
disableShakeToResetSleepTimer = false,
|
||||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||||
lockOrientation = LockOrientationSetting.NONE,
|
lockOrientation = LockOrientationSetting.NONE,
|
||||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||||
autoSleepTimer = false,
|
autoSleepTimer = false,
|
||||||
autoSleepTimerStartTime = "22:00",
|
autoSleepTimerStartTime = "22:00",
|
||||||
autoSleepTimerEndTime = "06:00",
|
autoSleepTimerEndTime = "06:00",
|
||||||
sleepTimerLength = 900000L, // 15 minutes
|
sleepTimerLength = 900000L, // 15 minutes
|
||||||
autoSleepTimerAutoRewind = false,
|
autoSleepTimerAutoRewind = false,
|
||||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false,
|
disableSleepTimerResetFeedback = false,
|
||||||
languageCode = "en-us",
|
enableSleepTimerAlmostDoneChime = false,
|
||||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
languageCode = "en-us",
|
||||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||||
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||||
|
androidAutoBrowseLimitForGrouping = 100,
|
||||||
|
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
val jumpBackwardsTimeMs
|
||||||
|
get() = jumpBackwardsTime * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
val jumpForwardTimeMs
|
||||||
|
get() = jumpForwardTime * 1000L
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
val autoSleepTimerStartHour
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
val autoSleepTimerStartMinute
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
val autoSleepTimerEndHour
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
val autoSleepTimerEndMinute
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||||
else {
|
else {
|
||||||
@@ -193,10 +240,10 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class DeviceData(
|
data class DeviceData(
|
||||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
|
||||||
var lastServerConnectionConfigId:String?,
|
var lastServerConnectionConfigId: String?,
|
||||||
var deviceSettings: DeviceSettings?,
|
var deviceSettings: DeviceSettings?,
|
||||||
var lastPlaybackSession: PlaybackSession?
|
var lastPlaybackSession: PlaybackSession?
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||||
@@ -205,4 +252,3 @@ data class DeviceData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
data class FolderScanResult(
|
|
||||||
var itemsAdded:Int,
|
|
||||||
var itemsUpdated:Int,
|
|
||||||
var itemsRemoved:Int,
|
|
||||||
var itemsUpToDate:Int,
|
|
||||||
val localFolder:LocalFolder,
|
|
||||||
val localLibraryItems:List<LocalLibraryItem>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LocalLibraryItemScanResult(
|
|
||||||
val updated:Boolean,
|
|
||||||
val localLibraryItem:LocalLibraryItem,
|
|
||||||
)
|
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
@@ -18,8 +17,7 @@ data class ItemInProgress(
|
|||||||
val isLocal: Boolean
|
val isLocal: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
|
||||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
var episode:PodcastEpisode? = null
|
var episode:PodcastEpisode? = null
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryAuthorItem(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
var description:String?,
|
||||||
|
var imagePath:String?,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var numBooks:Int?,
|
||||||
|
var libraryItems:MutableList<LibraryItem>?,
|
||||||
|
var series:MutableList<LibrarySeriesItem>?
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val bookCount get() = if (numBooks != null) numBooks else libraryItems!!.size
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getPortraitUri(): Uri {
|
||||||
|
if (imagePath == null) {
|
||||||
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.md_account_outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/authors/$id/image?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(getPortraitUri())
|
||||||
|
.setSubtitle("${bookCount} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryCollection(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
//var userId:String?,
|
||||||
|
var description:String?,
|
||||||
|
var books:MutableList<LibraryItem>?,
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val bookCount get() = if (books != null) books!!.size else 0
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val audiobookCount get() = books?.filter { book -> (book.media as Book).getAudioTracks().isNotEmpty() }?.size ?: 0
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__COLLECTION__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("${bookCount} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,10 +32,18 @@ class LibraryItem(
|
|||||||
var media:MediaType,
|
var media:MediaType,
|
||||||
var libraryFiles:MutableList<LibraryFile>?,
|
var libraryFiles:MutableList<LibraryFile>?,
|
||||||
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
|
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
|
||||||
var localLibraryItemId:String? // For Android Auto
|
var collapsedSeries: CollapsedSeries?,
|
||||||
|
var localLibraryItemId:String?, // For Android Auto
|
||||||
|
val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this
|
||||||
) : LibraryItemWrapper(id) {
|
) : LibraryItemWrapper(id) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val title get() = media.metadata.title
|
val title: String
|
||||||
|
get() {
|
||||||
|
if (collapsedSeries != null) {
|
||||||
|
return collapsedSeries!!.title
|
||||||
|
}
|
||||||
|
return media.metadata.title
|
||||||
|
}
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
|
||||||
@@ -45,62 +53,126 @@ class LibraryItem(
|
|||||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
|
||||||
|
}
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun checkHasTracks():Boolean {
|
fun checkHasTracks():Boolean {
|
||||||
return if (mediaType == "podcast") {
|
return media.checkHasTracks()
|
||||||
((media as Podcast).numEpisodes ?: 0) > 0
|
}
|
||||||
} else {
|
|
||||||
((media as Book).numTracks ?: 0) > 0
|
@get:JsonIgnore
|
||||||
|
val seriesSequence: String
|
||||||
|
get() {
|
||||||
|
if (mediaType != "podcast") {
|
||||||
|
return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty()
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val seriesSequenceParts: List<String>
|
||||||
|
get() {
|
||||||
|
if (seriesSequence.isEmpty()) {
|
||||||
|
return listOf("")
|
||||||
|
}
|
||||||
|
return seriesSequence.split(".", limit = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (collapsedSeries == null) {
|
||||||
|
if (localLibraryItemId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaConstants.METADATA_KEY_IS_EXPLICIT,
|
||||||
|
MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = if (localLibraryItemId != null) {
|
||||||
|
localLibraryItemId
|
||||||
|
} else if (collapsedSeries != null) {
|
||||||
|
if (authorId != null) {
|
||||||
|
"__LIBRARY__${libraryId}__AUTHOR_SERIES__${authorId}__${collapsedSeries!!.id}"
|
||||||
|
} else {
|
||||||
|
"__LIBRARY__${libraryId}__SERIES__${collapsedSeries!!.id}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
var subtitle = authorName
|
||||||
|
if (collapsedSeries != null) {
|
||||||
|
subtitle = "${collapsedSeries!!.numBooks} books"
|
||||||
|
}
|
||||||
|
var itemTitle = title
|
||||||
|
if (showSeriesNumber == true && seriesSequence != "") {
|
||||||
|
itemTitle = "$seriesSequence. $itemTitle"
|
||||||
|
}
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(itemTitle)
|
||||||
|
.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, authorId, showSeriesNumber, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, authorId, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
val extras = Bundle()
|
/*
|
||||||
|
This is needed so Android auto library hierarchy for author series can be implemented
|
||||||
if (localLibraryItemId != null) {
|
*/
|
||||||
extras.putLong(
|
return getMediaDescription(progress, ctx, null, null, null)
|
||||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
|
||||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress != null) {
|
|
||||||
if (progress.isFinished) {
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
|
||||||
)
|
|
||||||
extras.putDouble(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (mediaType != "podcast") {
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (media.metadata.explicit) {
|
|
||||||
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaId = localLibraryItemId ?: id
|
|
||||||
return MediaDescriptionCompat.Builder()
|
|
||||||
.setMediaId(mediaId)
|
|
||||||
.setTitle(title)
|
|
||||||
.setIconUri(getCoverUri())
|
|
||||||
.setSubtitle(authorName)
|
|
||||||
.setExtras(extras)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibrarySeriesItem(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
var description:String?,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var books:MutableList<LibraryItem>?,
|
||||||
|
var localLibraryItemId:String? // For Android Auto
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val audiobookCount: Int
|
||||||
|
get() {
|
||||||
|
if (books == null) return 0
|
||||||
|
val booksWithAudio = books?.filter { b -> b.media.checkHasTracks() }
|
||||||
|
return booksWithAudio?.size ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (localLibraryItemId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("$audiobookCount books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
if (episode != null) { // Get podcast episode audio track
|
if (episode != null) { // Get podcast episode audio track
|
||||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
@@ -91,15 +91,18 @@ class LocalLibraryItem(
|
|||||||
if (it.metadata === null) {
|
if (it.metadata === null) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Check that file exists
|
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||||
val file = File(it.metadata!!.path)
|
|
||||||
if (!file.exists()) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||||
|
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||||
val localEpisodeId = episode?.id
|
val localEpisodeId = episode?.id
|
||||||
@@ -108,7 +111,7 @@ class LocalLibraryItem(
|
|||||||
// Get current progress for local media
|
// Get current progress for local media
|
||||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0
|
||||||
|
|
||||||
|
|
||||||
val mediaMetadata = media.metadata
|
val mediaMetadata = media.metadata
|
||||||
|
|||||||
@@ -4,70 +4,64 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
|||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Used as a helper class to generate LocalLibraryItem from scan results
|
Used as a helper class to generate LocalLibraryItem from scan results
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalMediaItem(
|
data class LocalMediaItem(
|
||||||
var id:String,
|
var id: String,
|
||||||
var name: String,
|
var name: String,
|
||||||
var mediaType:String,
|
var mediaType: String,
|
||||||
var folderId:String,
|
var folderId: String,
|
||||||
var contentUrl:String,
|
var contentUrl: String,
|
||||||
var simplePath: String,
|
var basePath: String,
|
||||||
var basePath:String,
|
var absolutePath: String,
|
||||||
var absolutePath:String,
|
var audioTracks: MutableList<AudioTrack>,
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
var ebookFile: EBookFile?,
|
||||||
var ebookFile:EBookFile?,
|
var localFiles: MutableList<LocalFile>,
|
||||||
var localFiles:MutableList<LocalFile>,
|
var coverContentUrl: String?,
|
||||||
var coverContentUrl:String?,
|
var coverAbsolutePath: String?
|
||||||
var coverAbsolutePath:String?
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getDuration():Double {
|
fun getDuration(): Double {
|
||||||
var total = 0.0
|
var total = 0.0
|
||||||
audioTracks.forEach{ total += it.duration }
|
audioTracks.forEach { total += it.duration }
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getTotalSize():Long {
|
fun getTotalSize(): Long {
|
||||||
var total = 0L
|
var total = 0L
|
||||||
localFiles.forEach { total += it.size }
|
localFiles.forEach { total += it.size }
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadata():MediaTypeMetadata {
|
fun getMediaMetadata(): MediaTypeMetadata {
|
||||||
return if (mediaType == "book") {
|
return if (mediaType == "book") {
|
||||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
BookMetadata(
|
||||||
|
name,
|
||||||
|
null,
|
||||||
|
mutableListOf(),
|
||||||
|
mutableListOf(),
|
||||||
|
mutableListOf(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
PodcastMetadata(name, null, null, mutableListOf(), false)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getAudiobookChapters():List<BookChapter> {
|
|
||||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
|
||||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
|
||||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
|
||||||
}
|
|
||||||
// Multi-track make chapters from tracks
|
|
||||||
return audioTracks.map { it.getBookChapter() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getLocalLibraryItem():LocalLibraryItem {
|
|
||||||
val mediaMetadata = getMediaMetadata()
|
|
||||||
if (mediaType == "book") {
|
|
||||||
val chapters = getAudiobookChapters()
|
|
||||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
|
||||||
} else {
|
|
||||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
|
||||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class LocalMediaProgress(
|
|||||||
progress = if (finished) 1.0 else 0.0
|
progress = if (finished) 1.0 else 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finished) {
|
||||||
|
currentTime = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
isFinished = finished
|
isFinished = finished
|
||||||
lastUpdate = System.currentTimeMillis()
|
lastUpdate = System.currentTimeMillis()
|
||||||
finishedAt = if (isFinished) lastUpdate else null
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
class MediaItemHistory(
|
class MediaItemHistory(
|
||||||
var id: String, // media id
|
var id: String, // media id
|
||||||
var mediaDisplayTitle: String,
|
var mediaDisplayTitle: String,
|
||||||
var libraryItemId: String,
|
var libraryItemId: String,
|
||||||
var episodeId: String?,
|
var episodeId: String?,
|
||||||
var isLocal:Boolean,
|
var isLocal: Boolean,
|
||||||
var serverConnectionConfigId:String?,
|
var serverConnectionConfigId: String?,
|
||||||
var serverAddress:String?,
|
var serverAddress: String?,
|
||||||
var serverUserId:String?,
|
var serverUserId: String?,
|
||||||
var createdAt: Long,
|
var createdAt: Long,
|
||||||
var events:MutableList<MediaItemEvent>,
|
var events: MutableList<MediaItemEvent>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -12,6 +13,7 @@ import com.audiobookshelf.app.BuildConfig
|
|||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||||
|
import com.audiobookshelf.app.player.*
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.google.android.exoplayer2.MediaItem
|
import com.google.android.exoplayer2.MediaItem
|
||||||
@@ -19,61 +21,73 @@ import com.google.android.exoplayer2.MediaMetadata
|
|||||||
import com.google.android.gms.cast.MediaInfo
|
import com.google.android.gms.cast.MediaInfo
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.common.images.WebImage
|
import com.google.android.gms.common.images.WebImage
|
||||||
import com.audiobookshelf.app.player.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
class PlaybackSession(
|
class PlaybackSession(
|
||||||
var id:String,
|
var id: String,
|
||||||
var userId:String?,
|
var userId: String?,
|
||||||
var libraryItemId:String?,
|
var libraryItemId: String?,
|
||||||
var episodeId:String?,
|
var episodeId: String?,
|
||||||
var mediaType:String,
|
var mediaType: String,
|
||||||
var mediaMetadata:MediaTypeMetadata,
|
var mediaMetadata: MediaTypeMetadata,
|
||||||
var deviceInfo:DeviceInfo,
|
var deviceInfo: DeviceInfo,
|
||||||
var chapters:List<BookChapter>,
|
var chapters: List<BookChapter>,
|
||||||
var displayTitle: String?,
|
var displayTitle: String?,
|
||||||
var displayAuthor: String?,
|
var displayAuthor: String?,
|
||||||
var coverPath:String?,
|
var coverPath: String?,
|
||||||
var duration:Double,
|
var duration: Double,
|
||||||
var playMethod:Int,
|
var playMethod: Int,
|
||||||
var startedAt:Long,
|
var startedAt: Long,
|
||||||
var updatedAt:Long,
|
var updatedAt: Long,
|
||||||
var timeListening:Long,
|
var timeListening: Long,
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
var audioTracks: MutableList<AudioTrack>,
|
||||||
var currentTime:Double,
|
var currentTime: Double,
|
||||||
var libraryItem:LibraryItem?,
|
var libraryItem: LibraryItem?,
|
||||||
var localLibraryItem:LocalLibraryItem?,
|
var localLibraryItem: LocalLibraryItem?,
|
||||||
var localEpisodeId:String?,
|
var localEpisodeId: String?,
|
||||||
var serverConnectionConfigId:String?,
|
var serverConnectionConfigId: String?,
|
||||||
var serverAddress:String?,
|
var serverAddress: String?,
|
||||||
var mediaPlayer:String?
|
var mediaPlayer: String?
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
val isHLS
|
||||||
|
get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
val isDirectPlay
|
||||||
|
get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
val isLocal
|
||||||
|
get() = playMethod == PLAYMETHOD_LOCAL
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isPodcastEpisode get() = mediaType == "podcast"
|
val isPodcastEpisode
|
||||||
|
get() = mediaType == "podcast"
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
val currentTimeMs
|
||||||
|
get() = (currentTime * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
val totalDurationMs
|
||||||
|
get() = (getTotalDuration() * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
val localLibraryItemId
|
||||||
|
get() = localLibraryItem?.id ?: ""
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
val localMediaProgressId
|
||||||
|
get() =
|
||||||
|
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId
|
||||||
|
else "$localLibraryItemId-$localEpisodeId"
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val progress get() = currentTime / getTotalDuration()
|
val progress
|
||||||
|
get() = currentTime / getTotalDuration()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
val mediaItemId
|
||||||
@get:JsonIgnore
|
get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackIndex():Int {
|
fun getCurrentTrackIndex(): Int {
|
||||||
for (i in 0 until audioTracks.size) {
|
for (i in 0 until audioTracks.size) {
|
||||||
val track = audioTracks[i]
|
val track = audioTracks[i]
|
||||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||||
@@ -84,7 +98,7 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNextTrackIndex():Int {
|
fun getNextTrackIndex(): Int {
|
||||||
for (i in 0 until audioTracks.size) {
|
for (i in 0 until audioTracks.size) {
|
||||||
val track = audioTracks[i]
|
val track = audioTracks[i]
|
||||||
if (currentTimeMs < track.startOffsetMs) {
|
if (currentTimeMs < track.startOffsetMs) {
|
||||||
@@ -95,125 +109,203 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getChapterForTime(time:Long):BookChapter? {
|
fun getChapterForTime(time: Long): BookChapter? {
|
||||||
if (chapters.isEmpty()) return null
|
if (chapters.isEmpty()) return null
|
||||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
return chapters.find { time >= it.startMs && it.endMs > time }
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackEndTime():Long {
|
fun getCurrentTrackEndTime(): Long {
|
||||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
fun getNextChapterForTime(time: Long): BookChapter? {
|
||||||
if (chapters.isEmpty()) return null
|
if (chapters.isEmpty()) return null
|
||||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNextTrackEndTime():Long {
|
fun getNextTrackEndTime(): Long {
|
||||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackTimeMs():Long {
|
fun getCurrentTrackTimeMs(): Long {
|
||||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
val time = currentTime - currentTrack.startOffset
|
val time = currentTime - currentTrack.startOffset
|
||||||
return (time * 1000L).toLong()
|
return (time * 1000L).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getTrackStartOffsetMs(index:Int):Long {
|
fun getTrackStartOffsetMs(index: Int): Long {
|
||||||
if (index < 0 || index >= audioTracks.size) return 0L
|
if (index < 0 || index >= audioTracks.size) return 0L
|
||||||
val currentTrack = audioTracks[index]
|
val currentTrack = audioTracks[index]
|
||||||
return (currentTrack.startOffset * 1000L).toLong()
|
return (currentTrack.startOffset * 1000L).toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getTotalDuration():Double {
|
fun getTotalDuration(): Double {
|
||||||
var total = 0.0
|
var total = 0.0
|
||||||
audioTracks.forEach { total += it.duration }
|
audioTracks.forEach { total += it.duration }
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(ctx:Context): Uri {
|
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||||
|
// Safety check this playback session is the same one currently connected (should always be)
|
||||||
|
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(ctx: Context): Uri {
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||||
if (coverUri.toString().startsWith("file:")) {
|
if (coverUri.toString().startsWith("file:")) {
|
||||||
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
coverUri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
ctx,
|
||||||
|
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||||
|
coverUri.toFile()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return coverUri
|
||||||
|
?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
if (coverPath == null)
|
||||||
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
}
|
||||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||||
|
// As of v2.22.0 tracks use a different endpoint
|
||||||
|
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||||
|
if (checkIsServerVersionGte("2.22.0")) {
|
||||||
|
return if (isDirectPlay) {
|
||||||
|
Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
|
||||||
|
} else {
|
||||||
|
// Transcode uses HlsRouter on server
|
||||||
|
Uri.parse("$serverAddress${audioTrack.contentUrl}")
|
||||||
|
}
|
||||||
|
}
|
||||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bitmap for the cover art, once resolved */
|
||||||
|
@JsonIgnore
|
||||||
|
private var resolvedCoverBitmap: Bitmap? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
|
||||||
|
*/
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
val coverUri = getCoverUri(ctx)
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
val metadataBuilder =
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
.putString(
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||||
|
coverUri.toString()
|
||||||
|
)
|
||||||
|
|
||||||
// Local covers get bitmap
|
if (resolvedCoverBitmap != null) {
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
metadataBuilder
|
||||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
|
||||||
} else {
|
} else {
|
||||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
metadataBuilder
|
||||||
ImageDecoder.decodeBitmap(source)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||||
}
|
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
|
||||||
|
* are fetched asynchronously) and calls `onArtResolved` once it's available
|
||||||
|
*
|
||||||
|
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
|
||||||
|
*/
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
fun resolveCoverBitmapAsync(
|
||||||
|
ctx: Context,
|
||||||
|
coroutineScope: CoroutineScope,
|
||||||
|
onArtResolved: () -> Unit
|
||||||
|
): Job? {
|
||||||
val coverUri = getCoverUri(ctx)
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadata.Builder()
|
// Local covers get bitmap synchronously, no async fetch needed
|
||||||
.setTitle(displayTitle)
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
.setDisplayTitle(displayTitle)
|
resolvedCoverBitmap =
|
||||||
.setArtist(displayAuthor)
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
.setAlbumArtist(displayAuthor)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
.setSubtitle(displayAuthor)
|
} else {
|
||||||
.setAlbumTitle(displayAuthor)
|
val source: ImageDecoder.Source =
|
||||||
.setDescription(displayAuthor)
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
.setArtworkUri(coverUri)
|
ImageDecoder.decodeBitmap(source)
|
||||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
}
|
||||||
|
onArtResolved()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side cover: resolve the art bitmap async
|
||||||
|
return coroutineScope.launch {
|
||||||
|
val bitmap = resolveUriAsBitmap(ctx, coverUri)
|
||||||
|
bitmap?.let {
|
||||||
|
resolvedCoverBitmap = it
|
||||||
|
onArtResolved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getExoMediaMetadata(ctx: Context): MediaMetadata {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
|
val metadataBuilder =
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(displayTitle)
|
||||||
|
.setDisplayTitle(displayTitle)
|
||||||
|
.setArtist(displayAuthor)
|
||||||
|
.setAlbumArtist(displayAuthor)
|
||||||
|
.setSubtitle(displayAuthor)
|
||||||
|
.setAlbumTitle(displayAuthor)
|
||||||
|
.setDescription(displayAuthor)
|
||||||
|
.setArtworkUri(coverUri)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||||
|
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
fun getMediaItems(ctx: Context): List<MediaItem> {
|
||||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
for (audioTrack in audioTracks) {
|
for (audioTrack in audioTracks) {
|
||||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||||
@@ -221,50 +313,107 @@ class PlaybackSession(
|
|||||||
val mimeType = audioTrack.mimeType
|
val mimeType = audioTrack.mimeType
|
||||||
|
|
||||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||||
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
val mediaItem =
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setUri(mediaUri)
|
||||||
|
.setTag(queueItem)
|
||||||
|
.setMediaMetadata(mediaMetadata)
|
||||||
|
.setMimeType(mimeType)
|
||||||
|
.build()
|
||||||
mediaItems.add(mediaItem)
|
mediaItems.add(mediaItem)
|
||||||
}
|
}
|
||||||
return mediaItems
|
return mediaItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata {
|
||||||
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
val castMetadata =
|
||||||
|
com.google.android.gms.cast.MediaMetadata(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||||
|
)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
} else {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast always uses server cover uri
|
||||||
coverPath?.let {
|
coverPath?.let {
|
||||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
castMetadata.addImage(WebImage(coverUri))
|
||||||
}
|
}
|
||||||
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
castMetadata.putString(
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
com.google.android.gms.cast.MediaMetadata.KEY_ARTIST,
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
displayAuthor ?: ""
|
||||||
|
)
|
||||||
|
castMetadata.putString(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE,
|
||||||
|
displayAuthor ?: ""
|
||||||
|
)
|
||||||
|
castMetadata.putString(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE,
|
||||||
|
audioTrack.title
|
||||||
|
)
|
||||||
|
|
||||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
castMetadata.putInt(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER,
|
||||||
|
audioTrack.index
|
||||||
|
)
|
||||||
return castMetadata
|
return castMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem {
|
||||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||||
|
|
||||||
val mediaUri = getContentUri(audioTrack)
|
val mediaUri = getContentUri(audioTrack)
|
||||||
|
|
||||||
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
val mediaInfo =
|
||||||
setContentUrl(mediaUri.toString())
|
MediaInfo.Builder(mediaUri.toString())
|
||||||
setContentType(audioTrack.mimeType)
|
.apply {
|
||||||
setMetadata(castMetadata)
|
setContentUrl(mediaUri.toString())
|
||||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
setContentType(audioTrack.mimeType ?: "")
|
||||||
}.build()
|
setMetadata(castMetadata)
|
||||||
|
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
return MediaQueueItem.Builder(mediaInfo)
|
||||||
setPlaybackDuration(audioTrack.duration)
|
.apply { setPlaybackDuration(audioTrack.duration) }
|
||||||
}.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun clone():PlaybackSession {
|
fun clone(): PlaybackSession {
|
||||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
return PlaybackSession(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
libraryItemId,
|
||||||
|
episodeId,
|
||||||
|
mediaType,
|
||||||
|
mediaMetadata,
|
||||||
|
deviceInfo,
|
||||||
|
chapters,
|
||||||
|
displayTitle,
|
||||||
|
displayAuthor,
|
||||||
|
coverPath,
|
||||||
|
duration,
|
||||||
|
playMethod,
|
||||||
|
startedAt,
|
||||||
|
updatedAt,
|
||||||
|
timeListening,
|
||||||
|
audioTracks,
|
||||||
|
currentTime,
|
||||||
|
libraryItem,
|
||||||
|
localLibraryItem,
|
||||||
|
localEpisodeId,
|
||||||
|
serverConnectionConfigId,
|
||||||
|
serverAddress,
|
||||||
|
mediaPlayer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@@ -275,7 +424,25 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
fun getNewLocalMediaProgress(): LocalMediaProgress {
|
||||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
return LocalMediaProgress(
|
||||||
|
localMediaProgressId,
|
||||||
|
localLibraryItemId,
|
||||||
|
localEpisodeId,
|
||||||
|
getTotalDuration(),
|
||||||
|
progress,
|
||||||
|
currentTime,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
updatedAt,
|
||||||
|
startedAt,
|
||||||
|
null,
|
||||||
|
serverConnectionConfigId,
|
||||||
|
serverAddress,
|
||||||
|
userId,
|
||||||
|
libraryItemId,
|
||||||
|
episodeId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ import com.audiobookshelf.app.managers.DbManager
|
|||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.updateAppWidget
|
import com.audiobookshelf.app.updateAppWidget
|
||||||
|
|
||||||
|
/** Interface for widget event handling. */
|
||||||
interface WidgetEventEmitter {
|
interface WidgetEventEmitter {
|
||||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
/**
|
||||||
|
* Called when the player state changes.
|
||||||
|
* @param pns The PlayerNotificationService instance.
|
||||||
|
*/
|
||||||
|
fun onPlayerChanged(pns: PlayerNotificationService)
|
||||||
|
|
||||||
|
/** Called when the player is closed. */
|
||||||
fun onPlayerClosed()
|
fun onPlayerClosed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Singleton object for managing device-related operations. */
|
||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
const val tag = "DeviceManager"
|
const val tag = "DeviceManager"
|
||||||
|
|
||||||
@@ -25,20 +33,29 @@ object DeviceManager {
|
|||||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||||
|
|
||||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||||
val token get() = serverConnectionConfig?.token ?: ""
|
val serverAddress
|
||||||
val isConnectedToServer get() = serverConnectionConfig != null
|
get() = serverConnectionConfig?.address ?: ""
|
||||||
|
val serverUserId
|
||||||
|
get() = serverConnectionConfig?.userId ?: ""
|
||||||
|
val token
|
||||||
|
get() = serverConnectionConfig?.token ?: ""
|
||||||
|
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||||
|
val isConnectedToServer
|
||||||
|
get() = serverConnectionConfig != null
|
||||||
|
|
||||||
var widgetUpdater:WidgetEventEmitter? = null
|
var widgetUpdater: WidgetEventEmitter? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.d(tag, "Device Manager Singleton invoked")
|
Log.d(tag, "Device Manager Singleton invoked")
|
||||||
|
|
||||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null ||
|
||||||
|
deviceData.deviceSettings?.autoSleepTimerEndTime == null
|
||||||
|
) {
|
||||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00"
|
||||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||||
}
|
}
|
||||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||||
@@ -48,6 +65,10 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||||
}
|
}
|
||||||
|
// Initialize sleep timer almost done chime added in v0.9.81
|
||||||
|
if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) {
|
||||||
|
deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false
|
||||||
|
}
|
||||||
|
|
||||||
// Language added in v0.9.69
|
// Language added in v0.9.69
|
||||||
if (deviceData.deviceSettings?.languageCode == null) {
|
if (deviceData.deviceSettings?.languageCode == null) {
|
||||||
@@ -61,19 +82,79 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||||
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||||
}
|
}
|
||||||
|
if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) {
|
||||||
|
deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100
|
||||||
|
}
|
||||||
|
if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) {
|
||||||
|
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder =
|
||||||
|
AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBase64Id(id:String):String {
|
/**
|
||||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
* Encodes the given ID to a Base64 string.
|
||||||
|
* @param id The ID to encode.
|
||||||
|
* @return The Base64 encoded string.
|
||||||
|
*/
|
||||||
|
fun getBase64Id(id: String): String {
|
||||||
|
return android.util.Base64.encodeToString(
|
||||||
|
id.toByteArray(),
|
||||||
|
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
/**
|
||||||
if (id == null) return null
|
* Retrieves the server connection configuration for the given ID.
|
||||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
* @param id The ID of the server connection configuration.
|
||||||
|
* @return The ServerConnectionConfig instance or null if not found.
|
||||||
|
*/
|
||||||
|
fun getServerConnectionConfig(id: String?): ServerConnectionConfig? {
|
||||||
|
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkConnectivity(ctx:Context): Boolean {
|
/**
|
||||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
* Check if the currently connected server version is >= compareVersion
|
||||||
|
* Abs server only uses major.minor.patch
|
||||||
|
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||||
|
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* serverVersion=2.25.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||||
|
*
|
||||||
|
* serverVersion=2.26.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||||
|
*/
|
||||||
|
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||||
|
if (serverVersion == "") return false
|
||||||
|
if (compareVersion == "") return true
|
||||||
|
|
||||||
|
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
// Compare major, minor, and patch components
|
||||||
|
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||||
|
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||||
|
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||||
|
|
||||||
|
if (serverVersionComponent < compareVersionComponent) {
|
||||||
|
return false // Server version is less than compareVersion
|
||||||
|
} else if (serverVersionComponent > compareVersionComponent) {
|
||||||
|
return true // Server version is greater than compareVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // versions are equal in major, minor, and patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the network connectivity status.
|
||||||
|
* @param ctx The context to use for checking connectivity.
|
||||||
|
* @return True if connected to the internet, false otherwise.
|
||||||
|
*/
|
||||||
|
fun checkConnectivity(ctx: Context): Boolean {
|
||||||
|
val connectivityManager =
|
||||||
|
ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||||
if (capabilities != null) {
|
if (capabilities != null) {
|
||||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||||
@@ -90,35 +171,58 @@ object DeviceManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
/**
|
||||||
|
* Sets the last playback session.
|
||||||
|
* @param playbackSession The playback session to set.
|
||||||
|
*/
|
||||||
|
fun setLastPlaybackSession(playbackSession: PlaybackSession) {
|
||||||
deviceData.lastPlaybackSession = playbackSession
|
deviceData.lastPlaybackSession = playbackSession
|
||||||
dbManager.saveDeviceData(deviceData)
|
dbManager.saveDeviceData(deviceData)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initializeWidgetUpdater(context:Context) {
|
/**
|
||||||
|
* Initializes the widget updater.
|
||||||
|
* @param context The context to use for initializing the widget updater.
|
||||||
|
*/
|
||||||
|
fun initializeWidgetUpdater(context: Context) {
|
||||||
Log.d(tag, "Initializing widget updater")
|
Log.d(tag, "Initializing widget updater")
|
||||||
widgetUpdater = (object : WidgetEventEmitter {
|
widgetUpdater =
|
||||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
(object : WidgetEventEmitter {
|
||||||
|
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||||
|
val isPlaying = pns.currentPlayer.isPlaying
|
||||||
|
|
||||||
val isPlaying = pns.currentPlayer.isPlaying
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
for (widgetId in ids) {
|
||||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
updateAppWidget(
|
||||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
context,
|
||||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
appWidgetManager,
|
||||||
|
widgetId,
|
||||||
|
playbackSession,
|
||||||
|
isPlaying,
|
||||||
|
PlayerNotificationService.isClosed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (widgetId in ids) {
|
override fun onPlayerClosed() {
|
||||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
}
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
}
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
override fun onPlayerClosed() {
|
for (widgetId in ids) {
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
updateAppWidget(
|
||||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
context,
|
||||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
appWidgetManager,
|
||||||
for (widgetId in ids) {
|
widgetId,
|
||||||
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
deviceData.lastPlaybackSession,
|
||||||
}
|
false,
|
||||||
}
|
PlayerNotificationService.isClosed
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,362 +7,306 @@ import androidx.documentfile.provider.DocumentFile
|
|||||||
import com.anggrayudi.storage.file.*
|
import com.anggrayudi.storage.file.*
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
/** Creates local-library records from a completed download manifest. */
|
||||||
|
class FolderScanner(private val ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
private val tag = "FolderScanner"
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
data class DownloadItemScanResult(
|
||||||
|
val localLibraryItem: LocalLibraryItem,
|
||||||
|
var localMediaProgress: LocalMediaProgress?
|
||||||
|
)
|
||||||
|
|
||||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
private fun localLibraryItemId(mediaItemId: String) =
|
||||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
private fun createLocalFile(
|
||||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
part: DownloadItemPart,
|
||||||
|
externalFile: DocumentFile? = null
|
||||||
var localEpisodeId:String? = null
|
): LocalFile? {
|
||||||
var localLibraryItem:LocalLibraryItem?
|
if (part.isInternalStorage) {
|
||||||
if (downloadItem.mediaType == "book") {
|
val file = File(part.finalDestinationPath)
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
if (!file.exists()) return null
|
||||||
} else {
|
return LocalFile(
|
||||||
// Lookup or create podcast local library item
|
DeviceManager.getBase64Id(file.name),
|
||||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
file.name,
|
||||||
if (localLibraryItem == null) {
|
Uri.fromFile(file).toString(),
|
||||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
file.getBasePath(ctx),
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
file.absolutePath,
|
||||||
}
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
part.completedDestinationUri?.let { contentUrl ->
|
||||||
var foundEBookFile = false
|
val uri = Uri.parse(contentUrl)
|
||||||
|
val size =
|
||||||
|
try {
|
||||||
|
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
|
descriptor.statSize.coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
?: 0L
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
|
||||||
|
// though the URI remains readable. Keep the URI as the authoritative local-file location.
|
||||||
|
return LocalFile(
|
||||||
|
DeviceManager.getBase64Id(contentUrl),
|
||||||
|
part.filename,
|
||||||
|
contentUrl,
|
||||||
|
part.localFolderName,
|
||||||
|
part.finalDestinationPath,
|
||||||
|
mimeTypeFor(part),
|
||||||
|
size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
// file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
|
||||||
|
// SAF tree grant instead.
|
||||||
|
val document = externalFile
|
||||||
|
if (document == null || !document.exists()) {
|
||||||
|
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return LocalFile(
|
||||||
|
DeviceManager.getBase64Id(document.id),
|
||||||
|
document.name,
|
||||||
|
document.uri.toString(),
|
||||||
|
document.getBasePath(ctx),
|
||||||
|
document.getAbsolutePath(ctx),
|
||||||
|
document.mimeType,
|
||||||
|
document.length()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
private fun newLocalLibraryItem(
|
||||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
id: String,
|
||||||
|
downloadItem: DownloadItem,
|
||||||
if (file == null) {
|
basePath: String,
|
||||||
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
absolutePath: String,
|
||||||
} else {
|
contentUrl: String
|
||||||
if (downloadItemPart.audioTrack != null) {
|
) =
|
||||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
LocalLibraryItem(
|
||||||
Log.d(
|
id,
|
||||||
tag,
|
downloadItem.localFolder.id,
|
||||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
basePath,
|
||||||
|
absolutePath,
|
||||||
|
contentUrl,
|
||||||
|
false,
|
||||||
|
downloadItem.mediaType,
|
||||||
|
downloadItem.media.getLocalCopy(),
|
||||||
|
mutableListOf(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
downloadItem.serverConnectionConfigId,
|
||||||
|
downloadItem.serverAddress,
|
||||||
|
downloadItem.serverUserId,
|
||||||
|
downloadItem.libraryItemId
|
||||||
)
|
)
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
private fun scanParts(
|
||||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
item: DownloadItem,
|
||||||
val localFile = LocalFile(
|
localItem: LocalLibraryItem,
|
||||||
localFileId,
|
externalFolder: DocumentFile? = null,
|
||||||
file.name,
|
callback: (DownloadItemScanResult?) -> Unit
|
||||||
downloadItemPart.finalDestinationUri.toString(),
|
) {
|
||||||
file.getBasePath(ctx),
|
val tracks = mutableListOf<AudioTrack>()
|
||||||
file.absolutePath,
|
var foundEbook = false
|
||||||
file.getSimplePath(ctx),
|
var localEpisodeId: String? = null
|
||||||
file.mimeType,
|
|
||||||
file.length()
|
|
||||||
)
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
item.downloadItemParts.forEach { part ->
|
||||||
// Create new audio track
|
val externalFile =
|
||||||
val track = AudioTrack(
|
if (part.isInternalStorage) {
|
||||||
audioTrackFromServer.index,
|
null
|
||||||
audioTrackFromServer.startOffset,
|
} else {
|
||||||
audioTrackFromServer.duration,
|
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
|
||||||
localFile.filename ?: "",
|
?: resolveExternalFile(externalFolder, part)
|
||||||
localFile.contentUrl,
|
}
|
||||||
localFile.mimeType ?: "",
|
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
|
||||||
trackFileMetadata,
|
val localFile = createLocalFile(part, externalFile) ?: return@forEach
|
||||||
true,
|
when {
|
||||||
localFileId,
|
part.audioTrack != null -> {
|
||||||
null,
|
val serverTrack = part.audioTrack
|
||||||
audioTrackFromServer.index
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
)
|
localItem.localFiles.add(localFile)
|
||||||
audioTracks.add(track)
|
val metadata =
|
||||||
|
FileMetadata(
|
||||||
Log.d(
|
localFile.filename ?: "",
|
||||||
tag,
|
File(localFile.filename ?: "").extension,
|
||||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
localFile.absolutePath,
|
||||||
)
|
localFile.basePath,
|
||||||
|
localFile.size
|
||||||
// Add podcast episodes to library
|
)
|
||||||
downloadItemPart.episode?.let { podcastEpisode ->
|
val track =
|
||||||
val podcast = localLibraryItem.media as Podcast
|
AudioTrack(
|
||||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
serverTrack.index,
|
||||||
localEpisodeId = newEpisode.id
|
serverTrack.startOffset,
|
||||||
Log.d(
|
serverTrack.duration,
|
||||||
tag,
|
localFile.filename ?: "",
|
||||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
localFile.contentUrl,
|
||||||
)
|
localFile.mimeType ?: "",
|
||||||
|
metadata,
|
||||||
|
true,
|
||||||
|
localFile.id,
|
||||||
|
serverTrack.index
|
||||||
|
)
|
||||||
|
tracks.add(track)
|
||||||
|
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||||
|
part.episode?.let { episode ->
|
||||||
|
val podcast = localItem.media as Podcast
|
||||||
|
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (downloadItemPart.ebookFile != null) {
|
part.ebookFile != null -> {
|
||||||
foundEBookFile = true
|
foundEbook = true
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
localItem.localFiles.add(localFile)
|
||||||
val localFile = LocalFile(
|
(localItem.media as Book).ebookFile =
|
||||||
localFileId,
|
EBookFile(
|
||||||
file.name,
|
part.ebookFile.ino,
|
||||||
Uri.fromFile(file).toString(),
|
part.ebookFile.metadata,
|
||||||
file.getBasePath(ctx),
|
part.ebookFile.ebookFormat,
|
||||||
file.absolutePath,
|
true,
|
||||||
file.getSimplePath(ctx),
|
localFile.id,
|
||||||
file.mimeType,
|
localFile.contentUrl
|
||||||
file.length()
|
)
|
||||||
)
|
}
|
||||||
localLibraryItem.localFiles.add(localFile)
|
else -> {
|
||||||
|
localItem.coverAbsolutePath = localFile.absolutePath
|
||||||
val ebookFile = EBookFile(
|
localItem.coverContentUrl = localFile.contentUrl
|
||||||
downloadItemPart.ebookFile.ino,
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
downloadItemPart.ebookFile.metadata,
|
localItem.localFiles.add(localFile)
|
||||||
downloadItemPart.ebookFile.ebookFormat,
|
|
||||||
true,
|
|
||||||
localFileId,
|
|
||||||
localFile.contentUrl
|
|
||||||
)
|
|
||||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
|
||||||
} else {
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
|
||||||
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
|
||||||
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
if (tracks.isEmpty() && !foundEbook) {
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
callback(null)
|
||||||
return cb(null)
|
return
|
||||||
}
|
}
|
||||||
|
if (item.mediaType == "book") {
|
||||||
// For books sort audio tracks then set
|
tracks.sortBy { it.index }
|
||||||
if (downloadItem.mediaType == "book") {
|
var expectedIndex = 1
|
||||||
audioTracks.sortBy { it.index }
|
var offset = 0.0
|
||||||
|
tracks.forEach { track ->
|
||||||
var indexCheck = 1
|
track.index = expectedIndex++
|
||||||
var startOffset = 0.0
|
track.startOffset = offset
|
||||||
audioTracks.forEach { audioTrack ->
|
offset += track.duration
|
||||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
|
||||||
audioTrack.index = indexCheck
|
|
||||||
audioTrack.startOffset = startOffset
|
|
||||||
}
|
|
||||||
indexCheck++
|
|
||||||
startOffset += audioTrack.duration
|
|
||||||
}
|
}
|
||||||
|
localItem.media.setAudioTracks(tracks)
|
||||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
val result = DownloadItemScanResult(localItem, null)
|
||||||
|
item.userMediaProgress?.let { progress ->
|
||||||
// If library item had media progress then make local media progress and save
|
val progressId =
|
||||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
else "${localItem.id}-$localEpisodeId"
|
||||||
val newLocalMediaProgress = LocalMediaProgress(
|
result.localMediaProgress =
|
||||||
id = localMediaProgressId,
|
LocalMediaProgress(
|
||||||
localLibraryItemId = localLibraryItemId,
|
progressId,
|
||||||
localEpisodeId = localEpisodeId,
|
localItem.id,
|
||||||
duration = mediaProgress.duration,
|
localEpisodeId,
|
||||||
progress = mediaProgress.progress,
|
progress.duration,
|
||||||
currentTime = mediaProgress.currentTime,
|
progress.progress,
|
||||||
isFinished = mediaProgress.isFinished,
|
progress.currentTime,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
progress.isFinished,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
progress.ebookLocation,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
progress.ebookProgress,
|
||||||
startedAt = mediaProgress.startedAt,
|
progress.lastUpdate,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
progress.startedAt,
|
||||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
progress.finishedAt,
|
||||||
serverAddress = downloadItem.serverAddress,
|
item.serverConnectionConfigId,
|
||||||
serverUserId = downloadItem.serverUserId,
|
item.serverAddress,
|
||||||
libraryItemId = downloadItem.libraryItemId,
|
item.serverUserId,
|
||||||
episodeId = downloadItem.episodeId)
|
item.libraryItemId,
|
||||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
item.episodeId
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
)
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
|
||||||
}
|
}
|
||||||
|
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
callback(result)
|
||||||
|
|
||||||
cb(downloadItemScanResult)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan item after download and create local library item
|
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
if (subPath.isBlank()) return root
|
||||||
// If downloading to internal storage handle separately
|
var current = root
|
||||||
if (downloadItem.isInternalStorage) {
|
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
scanInternalDownloadItem(downloadItem, cb)
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||||
|
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||||
|
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||||
|
*/
|
||||||
|
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
|
||||||
|
if (folder == null) return null
|
||||||
|
folder.findFile(part.filename)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||||
|
return folder.listFiles().firstOrNull { document ->
|
||||||
|
document.name == part.filename ||
|
||||||
|
document.fullName == part.filename ||
|
||||||
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
|
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
|
||||||
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
|
document.fullName.substringBeforeLast('.') == expectedBaseName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeFor(part: DownloadItemPart): String? {
|
||||||
|
return part.audioTrack?.mimeType
|
||||||
|
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
|
"epub" -> "application/epub+zip"
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
|
||||||
|
if (item.isInternalStorage) {
|
||||||
|
val id = "local_${item.libraryItemId}"
|
||||||
|
val localItem =
|
||||||
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
|
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
|
||||||
|
scanParts(item, localItem, callback = callback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
if (root == null) {
|
||||||
|
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||||
var itemFolderId = ""
|
callback(null)
|
||||||
var itemFolderUrl = ""
|
return
|
||||||
var itemFolderBasePath = ""
|
|
||||||
var itemFolderAbsolutePath = ""
|
|
||||||
foldersFound.forEach {
|
|
||||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
|
||||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
|
||||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
|
||||||
itemFolderId = it.id
|
|
||||||
itemFolderUrl = it.uri.toString()
|
|
||||||
itemFolderBasePath = it.getBasePath(ctx)
|
|
||||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val itemFolder = findFolderByPath(root, item.itemSubfolder)
|
||||||
if (itemFolderUrl == "") {
|
if (itemFolder == null) {
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||||
return cb(null)
|
callback(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
val id = localLibraryItemId(itemFolder.id)
|
||||||
|
val localItem =
|
||||||
if (df == null) {
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
?: newLocalLibraryItem(
|
||||||
return cb(null)
|
id,
|
||||||
}
|
item,
|
||||||
|
itemFolder.getBasePath(ctx),
|
||||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
itemFolder.getAbsolutePath(ctx),
|
||||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
itemFolder.uri.toString()
|
||||||
|
)
|
||||||
// Search for files in media item folder
|
scanParts(item, localItem, itemFolder, callback)
|
||||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
|
||||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
|
||||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
|
||||||
|
|
||||||
var localEpisodeId:String? = null
|
|
||||||
var localLibraryItem:LocalLibraryItem?
|
|
||||||
if (downloadItem.mediaType == "book") {
|
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
|
||||||
} else {
|
|
||||||
// Lookup or create podcast local library item
|
|
||||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
|
||||||
var foundEBookFile = false
|
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
|
||||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
|
||||||
itemPart.filename == docFile.name
|
|
||||||
}
|
|
||||||
if (itemPart == null) {
|
|
||||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
|
||||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
|
||||||
}
|
|
||||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
|
||||||
val audioTrackFromServer = itemPart.audioTrack
|
|
||||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
// Create new audio track
|
|
||||||
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
|
||||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
|
||||||
audioTracks.add(track)
|
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
|
||||||
|
|
||||||
// Add podcast episodes to library
|
|
||||||
itemPart.episode?.let { podcastEpisode ->
|
|
||||||
val podcast = localLibraryItem.media as Podcast
|
|
||||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
|
||||||
localEpisodeId = newEpisode.id
|
|
||||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
|
||||||
}
|
|
||||||
} else if (itemPart.ebookFile != null) { // Ebook
|
|
||||||
foundEBookFile = true
|
|
||||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
|
||||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
|
||||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
|
||||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
|
||||||
} else { // Cover image
|
|
||||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
|
||||||
return cb(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For books sort audio tracks then set
|
|
||||||
if (downloadItem.mediaType == "book") {
|
|
||||||
audioTracks.sortBy { it.index }
|
|
||||||
|
|
||||||
var indexCheck = 1
|
|
||||||
var startOffset = 0.0
|
|
||||||
audioTracks.forEach { audioTrack ->
|
|
||||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
|
||||||
audioTrack.index = indexCheck
|
|
||||||
audioTrack.startOffset = startOffset
|
|
||||||
}
|
|
||||||
indexCheck++
|
|
||||||
startOffset += audioTrack.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
|
||||||
|
|
||||||
// If library item had media progress then make local media progress and save
|
|
||||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
|
||||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
|
||||||
val newLocalMediaProgress = LocalMediaProgress(
|
|
||||||
id = localMediaProgressId,
|
|
||||||
localLibraryItemId = localLibraryItemId,
|
|
||||||
localEpisodeId = localEpisodeId,
|
|
||||||
duration = mediaProgress.duration,
|
|
||||||
progress = mediaProgress.progress,
|
|
||||||
currentTime = mediaProgress.currentTime,
|
|
||||||
isFinished = mediaProgress.isFinished,
|
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
|
||||||
startedAt = mediaProgress.startedAt,
|
|
||||||
finishedAt = mediaProgress.finishedAt,
|
|
||||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
|
||||||
serverAddress = downloadItem.serverAddress,
|
|
||||||
serverUserId = downloadItem.serverUserId,
|
|
||||||
libraryItemId = downloadItem.libraryItemId,
|
|
||||||
episodeId = downloadItem.episodeId)
|
|
||||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
|
||||||
|
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
|
|
||||||
cb(downloadItemScanResult)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import android.content.Context
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLog
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import io.paperdb.Paper
|
import io.paperdb.Paper
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class DbManager {
|
class DbManager {
|
||||||
val tag = "DbManager"
|
val tag = "DbManager"
|
||||||
@@ -22,45 +23,47 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getDeviceData(): DeviceData {
|
fun getDeviceData(): DeviceData {
|
||||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
return Paper.book("device").read("data")
|
||||||
|
?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||||
}
|
}
|
||||||
fun saveDeviceData(deviceData: DeviceData) {
|
fun saveDeviceData(deviceData: DeviceData) {
|
||||||
Paper.book("device").write("data", deviceData)
|
Paper.book("device").write("data", deviceData)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
fun getLocalLibraryItems(mediaType: String? = null): MutableList<LocalLibraryItem> {
|
||||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
val localLibraryItems: MutableList<LocalLibraryItem> = mutableListOf()
|
||||||
Paper.book("localLibraryItems").allKeys.forEach {
|
Paper.book("localLibraryItems").allKeys.forEach {
|
||||||
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
if (localLibraryItem != null &&
|
||||||
|
(mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)
|
||||||
|
) {
|
||||||
localLibraryItems.add(localLibraryItem)
|
localLibraryItems.add(localLibraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return localLibraryItems
|
return localLibraryItems
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
fun getLocalLibraryItemsInFolder(folderId: String): List<LocalLibraryItem> {
|
||||||
val localLibraryItems = getLocalLibraryItems()
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
return localLibraryItems.filter {
|
return localLibraryItems.filter { it.folderId == folderId }
|
||||||
it.folderId == folderId
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? {
|
||||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? {
|
||||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? {
|
||||||
var podcastEpisode: PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
val localLibraryItem =
|
||||||
val podcast = localLibraryItem.media as Podcast
|
getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
val podcast = localLibraryItem.media as Podcast
|
||||||
podcastEpisode != null
|
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||||
}
|
podcastEpisode != null
|
||||||
|
}
|
||||||
return if (localLibraryItem != null) {
|
return if (localLibraryItem != null) {
|
||||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||||
} else {
|
} else {
|
||||||
@@ -68,14 +71,12 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
fun removeLocalLibraryItem(localLibraryItemId: String) {
|
||||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
fun saveLocalLibraryItems(localLibraryItems: List<LocalLibraryItem>) {
|
||||||
localLibraryItems.map {
|
localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) }
|
||||||
Paper.book("localLibraryItems").write(it.id, it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||||
@@ -83,28 +84,24 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
Paper.book("localFolders").write(localFolder.id, localFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
fun getLocalFolder(folderId: String): LocalFolder? {
|
||||||
return Paper.book("localFolders").read(folderId)
|
return Paper.book("localFolders").read(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAllLocalFolders():List<LocalFolder> {
|
fun getAllLocalFolders(): List<LocalFolder> {
|
||||||
val localFolders:MutableList<LocalFolder> = mutableListOf()
|
val localFolders: MutableList<LocalFolder> = mutableListOf()
|
||||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { localFolders.add(it) }
|
||||||
localFolders.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return localFolders
|
return localFolders
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeLocalFolder(folderId:String) {
|
fun removeLocalFolder(folderId: String) {
|
||||||
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||||
localLibraryItems.forEach {
|
localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) }
|
||||||
Paper.book("localLibraryItems").delete(it.id)
|
|
||||||
}
|
|
||||||
Paper.book("localFolders").delete(folderId)
|
Paper.book("localFolders").delete(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,29 +109,28 @@ class DbManager {
|
|||||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeDownloadItem(downloadItemId:String) {
|
fun removeDownloadItem(downloadItemId: String) {
|
||||||
Paper.book("downloadItems").delete(downloadItemId)
|
Paper.book("downloadItems").delete(downloadItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadItems():List<DownloadItem> {
|
fun getDownloadItems(): List<DownloadItem> {
|
||||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
val downloadItems: MutableList<DownloadItem> = mutableListOf()
|
||||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let { downloadItems.add(it) }
|
||||||
downloadItems.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return downloadItems
|
return downloadItems
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||||
}
|
}
|
||||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
// For books this will just be the localLibraryItemId for podcast episodes this will be
|
||||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
// "{localLibraryItemId}-{episodeId}"
|
||||||
|
fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? {
|
||||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||||
}
|
}
|
||||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
fun getAllLocalMediaProgress(): List<LocalMediaProgress> {
|
||||||
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
val mediaProgress: MutableList<LocalMediaProgress> = mutableListOf()
|
||||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||||
mediaProgress.add(it)
|
mediaProgress.add(it)
|
||||||
@@ -142,7 +138,7 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
return mediaProgress
|
return mediaProgress
|
||||||
}
|
}
|
||||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
fun removeLocalMediaProgress(localMediaProgressId: String) {
|
||||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,57 +147,82 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make sure all local file ids still exist
|
// Make sure all local file ids still exist
|
||||||
fun cleanLocalLibraryItems() {
|
fun cleanLocalLibraryItems(context: Context) {
|
||||||
val localLibraryItems = getLocalLibraryItems()
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
|
||||||
localLibraryItems.forEach { lli ->
|
localLibraryItems.forEach { lli ->
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
|
|
||||||
// Check local files
|
// Check local files
|
||||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
lli.localFiles =
|
||||||
|
lli.localFiles.filter { localFile ->
|
||||||
val file = File(localFile.absolutePath)
|
val exists = localFile.exists(context)
|
||||||
if (!file.exists()) {
|
if (!exists) {
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
Log.d(
|
||||||
hasUpdates = true
|
tag,
|
||||||
}
|
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||||
file.exists()
|
)
|
||||||
} as MutableList<LocalFile>
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
exists
|
||||||
|
} as
|
||||||
|
MutableList<LocalFile>
|
||||||
|
|
||||||
// Check audio tracks and episodes
|
// Check audio tracks and episodes
|
||||||
if (lli.isPodcast) {
|
if (lli.isPodcast) {
|
||||||
val podcast = lli.media as Podcast
|
val podcast = lli.media as Podcast
|
||||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
podcast.episodes =
|
||||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
podcast.episodes?.filter { ep ->
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||||
hasUpdates = true
|
Log.d(
|
||||||
}
|
tag,
|
||||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
"cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}"
|
||||||
} as MutableList<PodcastEpisode>
|
)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
ep.audioTrack != null &&
|
||||||
|
lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||||
|
} as
|
||||||
|
MutableList<PodcastEpisode>
|
||||||
} else {
|
} else {
|
||||||
val book = lli.media as Book
|
val book = lli.media as Book
|
||||||
book.tracks = book.tracks?.filter { track ->
|
book.tracks =
|
||||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
book.tracks?.filter { track ->
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||||
hasUpdates = true
|
Log.d(
|
||||||
}
|
tag,
|
||||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
"cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}"
|
||||||
} as MutableList<AudioTrack>
|
)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||||
|
} as
|
||||||
|
MutableList<AudioTrack>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cover still there
|
// Check cover still there
|
||||||
lli.coverAbsolutePath?.let {
|
lli.coverAbsolutePath?.let {
|
||||||
val coverFile = File(it)
|
val coverExists =
|
||||||
|
lli.localFiles.any { localFile ->
|
||||||
if (!coverFile.exists()) {
|
localFile.absolutePath == it && localFile.exists(context)
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
}
|
||||||
|
if (!coverExists) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||||
|
)
|
||||||
lli.coverAbsolutePath = null
|
lli.coverAbsolutePath = null
|
||||||
lli.coverContentUrl = null
|
lli.coverContentUrl = null
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (lli.serverConnectionConfigId == null) {
|
||||||
|
// Local-only item support was removed in app version 0.9.67, remove any remaining local
|
||||||
|
// only items beginning in 0.9.80
|
||||||
|
Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS")
|
||||||
|
Paper.book("localLibraryItems").delete(lli.id)
|
||||||
|
} else if (hasUpdates) {
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||||
}
|
}
|
||||||
@@ -215,11 +236,18 @@ class DbManager {
|
|||||||
localMediaProgress.forEach {
|
localMediaProgress.forEach {
|
||||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||||
if (!it.id.startsWith("local")) {
|
if (!it.id.startsWith("local")) {
|
||||||
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
// A bug on the server when syncing local media progress was replacing the media progress id
|
||||||
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
// causing duplicate progress. Remove them.
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)"
|
||||||
|
)
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
} else if (matchingLLI == null) {
|
} else if (matchingLLI == null) {
|
||||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing"
|
||||||
|
)
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
} else if (matchingLLI.isPodcast) {
|
} else if (matchingLLI.isPodcast) {
|
||||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||||
@@ -229,7 +257,10 @@ class DbManager {
|
|||||||
val podcast = matchingLLI.media as Podcast
|
val podcast = matchingLLI.media as Podcast
|
||||||
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||||
if (matchingLEp == null) {
|
if (matchingLEp == null) {
|
||||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing"
|
||||||
|
)
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,20 +269,20 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory)
|
||||||
}
|
}
|
||||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
fun getMediaItemHistory(id: String): MediaItemHistory? {
|
||||||
return Paper.book("mediaItemHistory").read(id)
|
return Paper.book("mediaItemHistory").read(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
Paper.book("playbackSession").write(playbackSession.id, playbackSession)
|
||||||
}
|
}
|
||||||
fun removePlaybackSession(playbackSessionId:String) {
|
fun removePlaybackSession(playbackSessionId: String) {
|
||||||
Paper.book("playbackSession").delete(playbackSessionId)
|
Paper.book("playbackSession").delete(playbackSessionId)
|
||||||
}
|
}
|
||||||
fun getPlaybackSessions():List<PlaybackSession> {
|
fun getPlaybackSessions(): List<PlaybackSession> {
|
||||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
val sessions: MutableList<PlaybackSession> = mutableListOf()
|
||||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||||
sessions.add(it)
|
sessions.add(it)
|
||||||
@@ -259,4 +290,36 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveLog(log: AbsLog) {
|
||||||
|
Paper.book("log").write(log.id, log)
|
||||||
|
}
|
||||||
|
fun getAllLogs(): List<AbsLog> {
|
||||||
|
val logs: MutableList<AbsLog> = mutableListOf()
|
||||||
|
Paper.book("log").allKeys.forEach { logId ->
|
||||||
|
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||||
|
}
|
||||||
|
return logs.sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
fun removeAllLogs() {
|
||||||
|
Paper.book("log").destroy()
|
||||||
|
}
|
||||||
|
fun cleanLogs() {
|
||||||
|
val numberOfHoursToKeep = 48
|
||||||
|
val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep)
|
||||||
|
val allLogs = getAllLogs()
|
||||||
|
var logsRemoved = 0
|
||||||
|
allLogs.forEach {
|
||||||
|
if (it.timestamp < keepLogCutoff) {
|
||||||
|
Paper.book("log").delete(it.id)
|
||||||
|
logsRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (logsRemoved > 0) {
|
||||||
|
AbsLogger.info(
|
||||||
|
"DbManager",
|
||||||
|
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.app.DownloadManager
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.StatFs
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.anggrayudi.storage.callback.FileCallback
|
|
||||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
|
||||||
import com.anggrayudi.storage.file.MimeType
|
|
||||||
import com.anggrayudi.storage.file.getAbsolutePath
|
|
||||||
import com.anggrayudi.storage.file.moveFileTo
|
|
||||||
import com.anggrayudi.storage.media.FileDescription
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
import com.audiobookshelf.app.device.FolderScanner
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
@@ -18,274 +12,501 @@ import com.audiobookshelf.app.models.DownloadItemPart
|
|||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import okhttp3.Call
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
/** Manages the process-owned queue for app-managed downloads. */
|
||||||
val tag = "DownloadItemManager"
|
class DownloadItemManager(
|
||||||
private val maxSimultaneousDownloads = 3
|
private val folderScanner: FolderScanner,
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private val context: Context,
|
||||||
|
private var clientEventEmitter: DownloadEventEmitter
|
||||||
|
) {
|
||||||
|
private val tag = "DownloadItemManager"
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val activeCalls = ConcurrentHashMap<String, Call>()
|
||||||
|
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||||
|
private val reservations = mutableMapOf<String, Long>()
|
||||||
|
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||||
|
private var watcherRunning = false
|
||||||
|
private val jacksonMapper =
|
||||||
|
jacksonObjectMapper()
|
||||||
|
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
enum class DownloadCheckStatus {
|
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||||
InProgress,
|
private set
|
||||||
Successful,
|
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||||
Failed
|
private set
|
||||||
}
|
|
||||||
|
|
||||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
|
||||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
|
||||||
|
|
||||||
interface DownloadEventEmitter {
|
interface DownloadEventEmitter {
|
||||||
fun onDownloadItem(downloadItem:DownloadItem)
|
fun onDownloadItem(downloadItem: DownloadItem)
|
||||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||||
fun onDownloadItemComplete(jsobj:JSObject)
|
fun onDownloadItemComplete(jsobj: JSObject)
|
||||||
|
fun onQueueChanged(hasWork: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalProgressCallback {
|
interface InternalProgressCallback {
|
||||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||||
fun onComplete(failed: Boolean)
|
fun onComplete(failed: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
init {
|
||||||
var isDownloading:Boolean = false
|
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
@Synchronized
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
clientEventEmitter = eventEmitter
|
||||||
|
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun restoreQueue() {
|
||||||
|
if (downloadItemQueue.isNotEmpty()) return
|
||||||
|
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||||
|
if (item.isDownloadFinished) {
|
||||||
|
downloadItemQueue.add(item)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
if (part.moved) return@forEach
|
||||||
|
if (item.terminalFailureAt != null && part.failed) return@forEach
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L
|
||||||
|
}
|
||||||
|
downloadItemQueue.add(item)
|
||||||
|
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||||
|
clientEventEmitter.onDownloadItem(item)
|
||||||
|
}
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||||
|
val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
|
||||||
|
if (existingItem != null) {
|
||||||
|
if (existingItem.terminalFailureAt != null) {
|
||||||
|
retryDownloadItem(existingItem)
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
persist(downloadItem, force = true)
|
||||||
downloadItemQueue.add(downloadItem)
|
downloadItemQueue.add(downloadItem)
|
||||||
clientEventEmitter.onDownloadItem(downloadItem)
|
clientEventEmitter.onDownloadItem(downloadItem)
|
||||||
checkUpdateDownloadQueue()
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkUpdateDownloadQueue() {
|
private fun retryDownloadItem(item: DownloadItem) {
|
||||||
for (downloadItem in downloadItemQueue) {
|
item.terminalFailureAt = null
|
||||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
if (nextDownloadItemParts.size > 0) {
|
part.isMoving = false
|
||||||
nextDownloadItemParts.forEach {
|
part.downloadId = null
|
||||||
if (it.isInternalStorage) {
|
part.retryCount = 0
|
||||||
val file = File(it.finalDestinationPath)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
|
||||||
val internalProgressCallback = (object : InternalProgressCallback {
|
|
||||||
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
|
||||||
it.bytesDownloaded = totalBytesWritten
|
|
||||||
it.progress = progress
|
|
||||||
}
|
|
||||||
override fun onComplete(failed:Boolean) {
|
|
||||||
it.failed = failed
|
|
||||||
it.completed = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
|
||||||
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
|
||||||
it.downloadId = 1
|
|
||||||
currentDownloadItemParts.add(it)
|
|
||||||
} else {
|
|
||||||
val dlRequest = it.getDownloadRequest()
|
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
it.downloadId = downloadId
|
|
||||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
|
||||||
currentDownloadItemParts.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
persist(item, force = true)
|
||||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startWatchingDownloads() {
|
@Synchronized
|
||||||
if (isDownloading) return // Already watching
|
fun cancelAll() {
|
||||||
|
activeCalls.values.forEach(Call::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
downloadItemQueue.forEach { item ->
|
||||||
|
item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() }
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
}
|
||||||
|
currentDownloadItemParts.clear()
|
||||||
|
reservations.clear()
|
||||||
|
downloadItemQueue.clear()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
@Synchronized
|
||||||
Log.d(tag, "Starting watching downloads")
|
fun hasWork(): Boolean =
|
||||||
isDownloading = true
|
downloadItemQueue.any { item ->
|
||||||
|
item.downloadItemParts.any { part ->
|
||||||
while (currentDownloadItemParts.size > 0) {
|
(!part.completed && !part.failed) || part.isMoving
|
||||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
|
||||||
for (downloadItemPart in itemParts) {
|
|
||||||
if (downloadItemPart.isInternalStorage) {
|
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
|
||||||
|
|
||||||
if (downloadItemPart.completed) {
|
|
||||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
|
||||||
downloadItem?.let {
|
|
||||||
checkDownloadItemFinished(it)
|
|
||||||
}
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
|
||||||
|
|
||||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
|
||||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun checkUpdateDownloadQueue() {
|
||||||
|
downloadItemQueue.toList().forEach { item ->
|
||||||
|
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
|
if (slots <= 0) return@forEach
|
||||||
|
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||||
|
val existingFile = findSharedStorageFile(part)
|
||||||
|
if (existingFile != null) {
|
||||||
|
part.bytesDownloaded = existingFile.length()
|
||||||
|
part.progress = 100L
|
||||||
|
part.completedDestinationUri = existingFile.uri.toString()
|
||||||
|
File(part.destinationPath).delete()
|
||||||
|
completePart(item, part)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
} else if (tryReserve(part)) startDownload(item, part)
|
||||||
|
else {
|
||||||
|
part.waitingForSpace = true
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
delay(500)
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
val stagingFile = File(part.destinationPath)
|
||||||
|
stagingFile.parentFile?.mkdirs()
|
||||||
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
currentDownloadItemParts.add(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
val activeConfig = DeviceManager.serverConnectionConfig
|
||||||
|
val token =
|
||||||
|
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||||
|
else
|
||||||
|
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||||
|
?: DeviceManager.token
|
||||||
|
activeCalls[part.id] =
|
||||||
|
InternalDownloadManager(
|
||||||
|
stagingFile,
|
||||||
|
part.fileSize,
|
||||||
|
object : InternalProgressCallback {
|
||||||
|
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.bytesDownloaded = totalBytesWritten
|
||||||
|
part.progress = progress
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
override fun onComplete(failed: Boolean) {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.failed = failed
|
||||||
|
part.completed = !failed
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ hasAvailableSpace(part) }
|
||||||
|
)
|
||||||
|
.download(serverUrl(item, part), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun startWatchingDownloads() {
|
||||||
|
if (watcherRunning) return
|
||||||
|
watcherRunning = true
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val activeParts =
|
||||||
|
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||||
|
activeParts.forEach(::handlePartUpdate)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
checkUpdateDownloadQueue()
|
checkUpdateDownloadQueue()
|
||||||
}
|
if (!hasWork()) {
|
||||||
}
|
watcherRunning = false
|
||||||
|
notifyQueueChanged()
|
||||||
Log.d(tag, "Finished watching downloads")
|
return@launch
|
||||||
isDownloading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
|
||||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
|
||||||
|
|
||||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
|
||||||
downloadManager.query(query).use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
|
||||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
|
||||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
|
||||||
|
|
||||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
|
||||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
|
||||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
|
||||||
|
|
||||||
return when (downloadStatus) {
|
|
||||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
|
|
||||||
downloadItemPart.completed = true
|
|
||||||
downloadItemPart.progress = 1
|
|
||||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
|
||||||
|
|
||||||
DownloadCheckStatus.Successful
|
|
||||||
}
|
|
||||||
DownloadManager.STATUS_FAILED -> {
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
|
||||||
downloadItemPart.completed = true
|
|
||||||
downloadItemPart.failed = true
|
|
||||||
|
|
||||||
DownloadCheckStatus.Failed
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
|
||||||
downloadItemPart.progress = percentProgress
|
|
||||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
|
||||||
|
|
||||||
DownloadCheckStatus.InProgress
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
delay(WATCH_INTERVAL_MS)
|
||||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
|
||||||
downloadItemPart.completed = true
|
|
||||||
downloadItemPart.failed = true
|
|
||||||
return DownloadCheckStatus.Failed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
if (downloadItem == null) {
|
val item =
|
||||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
?: run {
|
||||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
removeActivePart(part)
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
return
|
||||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
}
|
||||||
|
if (!part.completed && !part.failed) {
|
||||||
val fcb = object : FileCallback() {
|
val lastUpdate = part.lastUpdateTime ?: return
|
||||||
override fun onPrepare() {
|
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
Log.w(tag, "Download stalled: ${part.filename}")
|
||||||
}
|
activeCalls.remove(part.id)?.cancel()
|
||||||
override fun onFailed(errorCode: ErrorCode) {
|
failOrRetry(item, part, "Download stalled")
|
||||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
|
||||||
downloadItemPart.failed = true
|
|
||||||
downloadItemPart.isMoving = false
|
|
||||||
file?.delete()
|
|
||||||
checkDownloadItemFinished(downloadItem)
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
|
||||||
override fun onCompleted(result:Any) {
|
|
||||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
|
||||||
val resultDocFile = result as DocumentFile
|
|
||||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
|
||||||
|
|
||||||
// Rename to fix appended .mp3 on m4b/m4a files
|
|
||||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
|
||||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
|
||||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
|
||||||
resultDocFile.renameTo(downloadItemPart.filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadItemPart.moved = true
|
|
||||||
downloadItemPart.isMoving = false
|
|
||||||
checkDownloadItemFinished(downloadItem)
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (part.failed) {
|
||||||
|
failOrRetry(item, part, "Transfer failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||||
|
}
|
||||||
|
|
||||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
@Synchronized
|
||||||
if (localFolderFile == null) {
|
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||||
// fAILED
|
removeActivePart(part)
|
||||||
downloadItemPart.failed = true
|
part.retryCount += 1
|
||||||
Log.e(tag, "Local Folder File from uri is null")
|
reservations.remove(part.destinationPath)
|
||||||
checkDownloadItemFinished(downloadItem)
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||||
} else {
|
part.failed = true
|
||||||
downloadItemPart.isMoving = true
|
part.completed = false
|
||||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
part.downloadId = null
|
||||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
persist(item, force = true)
|
||||||
|
IncompleteDownloadCleanup.schedule(context, item)
|
||||||
|
notifyQueueChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
if (part.moved || part.isMoving) return
|
||||||
|
part.isMoving = true
|
||||||
|
val stagingFile = File(part.destinationPath)
|
||||||
|
val finalFile = File(part.finalDestinationPath)
|
||||||
|
finalFile.parentFile?.mkdirs()
|
||||||
|
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||||
|
try {
|
||||||
|
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||||
|
if (finalFile.exists() && !finalFile.renameTo(backup))
|
||||||
|
throw IllegalStateException("Could not protect existing file")
|
||||||
|
if (!stagingFile.renameTo(finalFile)) {
|
||||||
|
if (backup.exists()) backup.renameTo(finalFile)
|
||||||
|
throw IllegalStateException("Could not finalize internal staging file")
|
||||||
}
|
}
|
||||||
|
backup.delete()
|
||||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
completePart(item, part)
|
||||||
checkDownloadItemFinished(downloadItem)
|
} catch (e: Exception) {
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (downloadItem.isDownloadFinished) {
|
if (part.moved || part.isMoving) return
|
||||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
val root =
|
||||||
|
DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||||
|
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||||
|
part.isMoving = true
|
||||||
|
persist(item, force = true)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
if (!hasAvailableSpace(part))
|
||||||
|
throw IllegalStateException("Insufficient storage for SAF copy")
|
||||||
|
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||||
|
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||||
|
val folder =
|
||||||
|
synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) }
|
||||||
|
?: throw IllegalStateException("Could not create SAF destination folder")
|
||||||
|
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||||
|
folder.findFile(temporaryName)?.delete()
|
||||||
|
val temporary =
|
||||||
|
folder.createFile(mimeTypeFor(part), temporaryName)
|
||||||
|
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
FileInputStream(staging).use { input ->
|
||||||
|
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||||
|
?: throw IllegalStateException("Could not open SAF output stream")
|
||||||
|
}
|
||||||
|
if (temporary.length() != staging.length())
|
||||||
|
throw IllegalStateException("SAF copy size mismatch")
|
||||||
|
val existing = folder.findFile(part.filename)
|
||||||
|
if (existing != null && !existing.delete())
|
||||||
|
throw IllegalStateException("Could not replace existing file")
|
||||||
|
if (!temporary.renameTo(part.filename))
|
||||||
|
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||||
|
val destination =
|
||||||
|
folder.findFile(part.filename)
|
||||||
|
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||||
|
if (destination.length() != staging.length())
|
||||||
|
throw IllegalStateException("SAF final size mismatch")
|
||||||
|
if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}")
|
||||||
|
part.completedDestinationUri = destination.uri.toString()
|
||||||
|
completePart(item, part)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
@Synchronized
|
||||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
Log.e(tag, message)
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, message)
|
||||||
|
}
|
||||||
|
|
||||||
val jsobj = JSObject()
|
@Synchronized
|
||||||
jsobj.put("libraryItemId", downloadItem.id)
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
part.moved = true
|
||||||
|
part.completed = true
|
||||||
|
part.failed = false
|
||||||
|
part.isMoving = false
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
removeActivePart(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
}
|
||||||
|
|
||||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
if (!item.isDownloadFinished) return
|
||||||
}
|
scope.launch {
|
||||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
val event =
|
||||||
}
|
JSObject().apply {
|
||||||
|
put("libraryItemId", item.id)
|
||||||
launch(Dispatchers.Main) {
|
put("localFolderId", item.localFolder.id)
|
||||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
scanResult?.localLibraryItem?.let {
|
||||||
downloadItemQueue.remove(downloadItem)
|
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
}
|
||||||
}
|
scanResult?.localMediaProgress?.let {
|
||||||
|
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientEventEmitter.onDownloadItemComplete(event)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
downloadItemQueue.remove(item)
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
notifyQueueChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
|
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
staging.parentFile?.mkdirs()
|
||||||
|
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||||
|
val remaining =
|
||||||
|
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||||
|
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||||
|
val key = storageKey(staging)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||||
|
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||||
|
reservations[part.destinationPath] = required
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statFsFor(staging: File): StatFs {
|
||||||
|
var directory = staging.parentFile ?: context.filesDir
|
||||||
|
directory.mkdirs()
|
||||||
|
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||||
|
return StatFs(directory.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storageKey(file: File): String =
|
||||||
|
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
|
||||||
|
else "external"
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun removeActivePart(part: DownloadItemPart) {
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
currentDownloadItemParts.remove(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||||
|
lastPersistTime[item.id] = now
|
||||||
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyQueueChanged() {
|
||||||
|
clientEventEmitter.onQueueChanged(hasWork())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
activeCalls.values.forEach(Call::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||||
|
var current = root
|
||||||
|
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? {
|
||||||
|
if (part.isInternalStorage) return null
|
||||||
|
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null
|
||||||
|
var folder = root
|
||||||
|
part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
folder = folder.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
val file = folder.findFile(part.filename) ?: return null
|
||||||
|
if (!file.isFile) return null
|
||||||
|
if (part.fileSize > 0L && file.length() != part.fileSize) return null
|
||||||
|
if (part.fileSize <= 0L && file.length() <= 0L) return null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||||
|
part.audioTrack?.mimeType
|
||||||
|
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
|
"epub" -> "application/epub+zip"
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||||
|
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
|
return "${item.serverAddress}${part.serverPath}$rawCover"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||||
|
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||||
|
const val WATCH_INTERVAL_MS = 1_000L
|
||||||
|
const val STALL_TIMEOUT_MS = 60_000L
|
||||||
|
const val MAX_RETRIES = 5
|
||||||
|
const val PERSIST_INTERVAL_MS = 2_000L
|
||||||
|
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||||
|
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Removes terminally failed downloads after their retention window elapses. */
|
||||||
|
object IncompleteDownloadCleanup {
|
||||||
|
private const val tag = "IncompleteDownloadCleanup"
|
||||||
|
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||||
|
private const val WORK_PREFIX = "incomplete-download-"
|
||||||
|
|
||||||
|
fun schedule(context: Context, item: DownloadItem) {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return
|
||||||
|
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||||
|
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||||
|
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, itemId: String) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
|
||||||
|
fun cleanupExpired(context: Context) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
DeviceManager.dbManager.getDownloadItems()
|
||||||
|
.filter { item -> isEligible(item, now) }
|
||||||
|
.forEach { item ->
|
||||||
|
deleteItem(context, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return false
|
||||||
|
if (now - failedAt < RETENTION_MS) return false
|
||||||
|
return item.downloadItemParts.all { part ->
|
||||||
|
part.moved || (part.failed && !part.isMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||||
|
if (part.isInternalStorage && part.moved) {
|
||||||
|
deleteAppOwnedFile(context, File(part.finalDestinationPath))
|
||||||
|
} else if (!part.isInternalStorage && part.moved) {
|
||||||
|
part.completedDestinationUri?.let { uriString ->
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
cancel(context, item.id)
|
||||||
|
Log.i(tag, "Deleted terminally failed download item ${item.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||||
|
val path = file.absolutePath
|
||||||
|
val internal = context.filesDir.absolutePath
|
||||||
|
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||||
|
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||||
|
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
||||||
|
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||||
|
} else {
|
||||||
|
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
DbManager.initialize(applicationContext)
|
||||||
|
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,135 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
import java.io.File
|
||||||
import okhttp3.*
|
import java.io.FileOutputStream
|
||||||
import java.io.*
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
/** Streams a download into an app-owned staging file. */
|
||||||
|
class InternalDownloadManager(
|
||||||
|
private val destinationFile: File,
|
||||||
|
private val expectedSize: Long,
|
||||||
|
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||||
|
private val hasAvailableSpace: () -> Boolean
|
||||||
|
) {
|
||||||
private val tag = "InternalDownloadManager"
|
private val tag = "InternalDownloadManager"
|
||||||
|
/**
|
||||||
|
* Starts or resumes a download.
|
||||||
|
*
|
||||||
|
* @param url download URL
|
||||||
|
* @param token access token sent in the Authorization header
|
||||||
|
* @return active call, used to cancel a stalled transfer
|
||||||
|
*/
|
||||||
|
fun download(url: String, token: String): Call {
|
||||||
|
destinationFile.parentFile?.mkdirs()
|
||||||
|
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||||
|
val request =
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept-Encoding", "identity")
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||||
|
.build()
|
||||||
|
val call = client.newCall(request)
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "Download URL failed", e)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
private val client: OkHttpClient = OkHttpClient()
|
override fun onResponse(call: Call, response: Response) {
|
||||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
response.use {
|
||||||
|
try {
|
||||||
|
if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize
|
||||||
|
) {
|
||||||
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val append =
|
||||||
|
existingBytes > 0L &&
|
||||||
|
response.code == 206 &&
|
||||||
|
hasExpectedRange(response, existingBytes)
|
||||||
|
if (existingBytes > 0L && !append && response.code != 200) {
|
||||||
|
Log.e(
|
||||||
|
tag,
|
||||||
|
"Invalid resume response ${response.code} for offset $existingBytes"
|
||||||
|
)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
Log.e(tag, "Download HTTP failure ${response.code}")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
val startingBytes = if (append) existingBytes else 0L
|
||||||
fun download(url:String) {
|
val responseLength = response.body!!.contentLength()
|
||||||
val request: Request = Request.Builder().url(url).build()
|
val totalLength =
|
||||||
client.newCall(request).enqueue(object : Callback {
|
if (expectedSize > 0L) expectedSize
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
else if (responseLength >= 0L) startingBytes + responseLength else 0L
|
||||||
Log.e(tag, "download URL $url FAILED")
|
|
||||||
progressCallback.onComplete(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
FileOutputStream(destinationFile, append).use { output ->
|
||||||
val responseBody: ResponseBody = response.body
|
response.body!!.byteStream().use { input ->
|
||||||
?: throw IllegalStateException("Response doesn't contain a file")
|
val buffer = ByteArray(CHUNK_SIZE)
|
||||||
|
var totalBytes = startingBytes
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
if (!hasAvailableSpace())
|
||||||
|
throw IOException("Download paused to preserve free storage")
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
totalBytes += read
|
||||||
|
val progress =
|
||||||
|
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||||
|
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||||
writer.write(responseBody.byteStream(), length)
|
Log.e(
|
||||||
}
|
tag,
|
||||||
})
|
"Downloaded size ${destinationFile.length()} did not match $expectedSize"
|
||||||
|
)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
} else {
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(tag, "Could not write staging file", e)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return call
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(Exception::class)
|
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||||
override fun close() {
|
val range = response.header("Content-Range") ?: return false
|
||||||
writer.close()
|
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||||
}
|
return match.groupValues[1].toLongOrNull() == offset &&
|
||||||
}
|
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||||
|
}
|
||||||
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
|
||||||
AutoCloseable {
|
private companion object {
|
||||||
private val outputStream: OutputStream
|
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||||
|
val client =
|
||||||
init {
|
OkHttpClient.Builder()
|
||||||
this.outputStream = outputStream
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
this.progressCallback = progressCallback
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
}
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
@Throws(IOException::class)
|
|
||||||
fun write(inputStream: InputStream?, length: Long): Long {
|
|
||||||
BufferedInputStream(inputStream).use { input ->
|
|
||||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
|
||||||
var readBytes: Int
|
|
||||||
var totalBytes: Long = 0
|
|
||||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
|
||||||
totalBytes += readBytes.toLong()
|
|
||||||
outputStream.write(dataBuffer, 0, readBytes)
|
|
||||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
|
||||||
}
|
|
||||||
progressCallback.onComplete(false)
|
|
||||||
return totalBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun close() {
|
|
||||||
outputStream.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHUNK_SIZE = 1024
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
class SecureStorage(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_LENGTH = 12
|
||||||
|
private const val TAG_LENGTH = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and stores a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
|
||||||
|
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||||
|
val combined = cipher.iv + encryptedBytes
|
||||||
|
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(serverConnectionId: String): String? {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||||
|
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||||
|
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||||
|
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||||
|
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a refresh token exists for a specific server connection
|
||||||
|
*/
|
||||||
|
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
} else {
|
||||||
|
createKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createKey(): SecretKey {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||||
|
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenSpec)
|
||||||
|
return keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +1,221 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.metrics.PlaybackSession
|
import android.media.MediaPlayer
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||||
import java.text.SimpleDateFormat
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||||
|
|
||||||
|
class SleepTimerManager
|
||||||
|
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||||
private val tag = "SleepTimerManager"
|
private val tag = "SleepTimerManager"
|
||||||
|
|
||||||
private var sleepTimerTask:TimerTask? = null
|
private var sleepTimerTask: TimerTask? = null
|
||||||
private var sleepTimerRunning:Boolean = false
|
private var sleepTimerRunning: Boolean = false
|
||||||
private var sleepTimerEndTime:Long = 0L
|
private var sleepTimerEndTime: Long = 0L
|
||||||
private var sleepTimerLength:Long = 0L
|
private var sleepTimerLength: Long = 0L
|
||||||
private var sleepTimerElapsed:Long = 0L
|
private var sleepTimerElapsed: Long = 0L
|
||||||
private var sleepTimerFinishedAt:Long = 0L
|
private var sleepTimerFinishedAt: Long = 0L
|
||||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
private var isAutoSleepTimer: Boolean = false // When timer was auto-set
|
||||||
private var isFirstAutoSleepTimer: Boolean = true
|
private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period
|
||||||
private var sleepTimerSessionId:String = ""
|
private var sleepTimerSessionId: String = ""
|
||||||
|
|
||||||
private fun getCurrentTime():Long {
|
/**
|
||||||
|
* Gets the current time from the player notification service.
|
||||||
|
* @return Long - the current time in milliseconds.
|
||||||
|
*/
|
||||||
|
private fun getCurrentTime(): Long {
|
||||||
return playerNotificationService.getCurrentTime()
|
return playerNotificationService.getCurrentTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDuration():Long {
|
/**
|
||||||
|
* Gets the duration of the current playback.
|
||||||
|
* @return Long - the duration in milliseconds.
|
||||||
|
*/
|
||||||
|
private fun getDuration(): Long {
|
||||||
return playerNotificationService.getDuration()
|
return playerNotificationService.getDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getIsPlaying():Boolean {
|
/**
|
||||||
|
* Checks if the player is currently playing.
|
||||||
|
* @return Boolean - true if the player is playing, false otherwise.
|
||||||
|
*/
|
||||||
|
private fun getIsPlaying(): Boolean {
|
||||||
return playerNotificationService.currentPlayer.isPlaying
|
return playerNotificationService.currentPlayer.isPlaying
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setVolume(volume:Float) {
|
/**
|
||||||
|
* Gets the playback speed of the player.
|
||||||
|
* @return Float - the playback speed.
|
||||||
|
*/
|
||||||
|
private fun getPlaybackSpeed(): Float {
|
||||||
|
return playerNotificationService.currentPlayer.playbackParameters.speed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the volume of the player.
|
||||||
|
* @param volume Float - the volume level to set.
|
||||||
|
*/
|
||||||
|
private fun setVolume(volume: Float) {
|
||||||
playerNotificationService.currentPlayer.volume = volume
|
playerNotificationService.currentPlayer.volume = volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pauses the player. */
|
||||||
private fun pause() {
|
private fun pause() {
|
||||||
playerNotificationService.currentPlayer.pause()
|
playerNotificationService.currentPlayer.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plays the player. */
|
||||||
private fun play() {
|
private fun play() {
|
||||||
playerNotificationService.currentPlayer.play()
|
playerNotificationService.currentPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
/**
|
||||||
|
* Gets the remaining time of the sleep timer in seconds.
|
||||||
|
* @param speed Float - the playback speed of the player, default value is 1.
|
||||||
|
* @return Int - the remaining time in seconds.
|
||||||
|
*/
|
||||||
|
private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int {
|
||||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||||
}
|
}
|
||||||
// For chapter end timer
|
// For chapter end timer
|
||||||
if (sleepTimerEndTime <= 0) return 0
|
if (sleepTimerEndTime <= 0) return 0
|
||||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
/**
|
||||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
* Sets the sleep timer.
|
||||||
|
* @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time.
|
||||||
|
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
private fun setSleepTimer(time: Long): Boolean {
|
||||||
|
Log.d(tag, "Setting Sleep Timer for $time")
|
||||||
sleepTimerTask?.cancel()
|
sleepTimerTask?.cancel()
|
||||||
sleepTimerRunning = true
|
sleepTimerRunning = true
|
||||||
sleepTimerFinishedAt = 0L
|
sleepTimerFinishedAt = 0L
|
||||||
sleepTimerElapsed = 0L
|
sleepTimerElapsed = 0L
|
||||||
setVolume(1f)
|
setVolume(1f)
|
||||||
|
|
||||||
// Register shake sensor
|
if (time == 0L) {
|
||||||
playerNotificationService.registerSensor()
|
// Get the current chapter time and set the sleep timer to the end of the chapter
|
||||||
|
val chapterEndTime = this.getChapterEndTime()
|
||||||
|
|
||||||
val currentTime = getCurrentTime()
|
if (chapterEndTime == null) {
|
||||||
if (isChapterTime) {
|
Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session")
|
||||||
if (currentTime > time) {
|
|
||||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
sleepTimerEndTime = time
|
|
||||||
sleepTimerLength = 0
|
val currentTime = getCurrentTime()
|
||||||
|
if (currentTime > chapterEndTime) {
|
||||||
|
Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sleepTimerEndTime = chapterEndTime
|
||||||
|
|
||||||
if (sleepTimerEndTime > getDuration()) {
|
if (sleepTimerEndTime > getDuration()) {
|
||||||
sleepTimerEndTime = getDuration()
|
sleepTimerEndTime = getDuration()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sleepTimerLength = time
|
|
||||||
sleepTimerEndTime = 0L
|
sleepTimerEndTime = 0L
|
||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
// Set sleep timer length. Will be 0L if using chapter end time
|
||||||
|
sleepTimerLength = time
|
||||||
|
|
||||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
// Register shake sensor
|
||||||
Handler(Looper.getMainLooper()).post {
|
playerNotificationService.registerSensor()
|
||||||
if (getIsPlaying()) {
|
|
||||||
sleepTimerElapsed += 1000L
|
|
||||||
|
|
||||||
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining > 0) {
|
sleepTimerTask =
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||||
}
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
if (getIsPlaying()) {
|
||||||
|
sleepTimerElapsed += 1000L
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining <= 0) {
|
val sleepTimeSecondsRemaining =
|
||||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||||
pause()
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s"
|
||||||
|
)
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
if (sleepTimeSecondsRemaining > 0) {
|
||||||
clearSleepTimer()
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
sleepTimeSecondsRemaining,
|
||||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
isAutoSleepTimer
|
||||||
// Start fading out audio down to 10% volume
|
)
|
||||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
}
|
||||||
val volume = 1f - (percentToReduce * 0.9f)
|
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||||
setVolume(volume)
|
playChimeSound()
|
||||||
} else {
|
}
|
||||||
setVolume(1f)
|
|
||||||
}
|
if (sleepTimeSecondsRemaining <= 0) {
|
||||||
}
|
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||||
}
|
pause()
|
||||||
}
|
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(
|
||||||
|
getCurrentTime()
|
||||||
|
)
|
||||||
|
clearSleepTimer()
|
||||||
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
|
} else if (sleepTimeSecondsRemaining <= 60 &&
|
||||||
|
DeviceManager.deviceData
|
||||||
|
.deviceSettings
|
||||||
|
?.disableSleepTimerFadeOut != true
|
||||||
|
) {
|
||||||
|
// Start fading out audio down to 10% volume
|
||||||
|
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||||
|
val volume = 1f - (percentToReduce * 0.9f)
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining"
|
||||||
|
)
|
||||||
|
setVolume(volume)
|
||||||
|
} else {
|
||||||
|
setVolume(1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
/**
|
||||||
|
* Sets a manual sleep timer.
|
||||||
|
* @param playbackSessionId String - the playback session ID.
|
||||||
|
* @param time Long - the time to set the sleep timer for.
|
||||||
|
* @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise.
|
||||||
|
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean {
|
||||||
sleepTimerSessionId = playbackSessionId
|
sleepTimerSessionId = playbackSessionId
|
||||||
isAutoSleepTimer = false
|
isAutoSleepTimer = false
|
||||||
return setSleepTimer(time, isChapterTime)
|
if (isChapterTime) {
|
||||||
|
Log.d(tag, "Setting manual sleep timer for end of chapter")
|
||||||
|
return setSleepTimer(0L)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Setting manual sleep timer for $time")
|
||||||
|
return setSleepTimer(time)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears the sleep timer. */
|
||||||
private fun clearSleepTimer() {
|
private fun clearSleepTimer() {
|
||||||
sleepTimerTask?.cancel()
|
sleepTimerTask?.cancel()
|
||||||
sleepTimerTask = null
|
sleepTimerTask = null
|
||||||
@@ -138,32 +226,36 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
setVolume(1f)
|
setVolume(1f)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSleepTimerTime():Long {
|
/**
|
||||||
|
* Gets the sleep timer end time.
|
||||||
|
* @return Long - the sleep timer end time in milliseconds.
|
||||||
|
*/
|
||||||
|
fun getSleepTimerTime(): Long {
|
||||||
return sleepTimerEndTime
|
return sleepTimerEndTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancels the sleep timer. */
|
||||||
fun cancelSleepTimer() {
|
fun cancelSleepTimer() {
|
||||||
Log.d(tag, "Canceling Sleep Timer")
|
Log.d(tag, "Canceling Sleep Timer")
|
||||||
|
|
||||||
if (isAutoSleepTimer) {
|
if (isAutoSleepTimer) {
|
||||||
Log.i(tag, "Disabling auto sleep timer")
|
Log.i(tag, "Disabling auto sleep timer for this time period")
|
||||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
autoTimerDisabled = true
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSleepTimer()
|
clearSleepTimer()
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vibrate when resetting sleep timer
|
/** Provides vibration feedback when resetting the sleep timer. */
|
||||||
private fun vibrateFeedback() {
|
private fun vibrateFeedback() {
|
||||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||||
|
|
||||||
val context = playerNotificationService.getContext()
|
val context = playerNotificationService.getContext()
|
||||||
val vibrator:Vibrator
|
val vibrator: Vibrator
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val vibratorManager =
|
val vibratorManager =
|
||||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
vibrator = vibratorManager.defaultVibrator
|
vibrator = vibratorManager.defaultVibrator
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -172,18 +264,32 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
|
|
||||||
vibrator.let {
|
vibrator.let {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1)
|
||||||
it.vibrate(vibrationEffect)
|
it.vibrate(vibrationEffect)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION") it.vibrate(10)
|
||||||
it.vibrate(10)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the chapter end time for use in End of Chapter timers
|
/** Plays chime sound */
|
||||||
// if less than 2s remain in chapter then use the next chapter
|
private fun playChimeSound() {
|
||||||
private fun getChapterEndTime():Long? {
|
AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound")
|
||||||
|
val ctx = playerNotificationService.getContext()
|
||||||
|
val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell)
|
||||||
|
mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME)
|
||||||
|
mediaPlayer.start()
|
||||||
|
mediaPlayer.setOnCompletionListener {
|
||||||
|
mediaPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||||
|
* the chapter, then use the next chapter.
|
||||||
|
* @return Long? - the chapter end time in milliseconds, or null if there is no current session.
|
||||||
|
*/
|
||||||
|
private fun getChapterEndTime(): Long? {
|
||||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||||
if (currentChapterEndTimeMs == null) {
|
if (currentChapterEndTimeMs == null) {
|
||||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||||
@@ -191,11 +297,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
}
|
}
|
||||||
|
|
||||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||||
return if (timeLeftInChapter < 2000L) {
|
// If less than 10 seconds remain in the chapter, set the timer to the next chapter or track
|
||||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
// This handles the auto-rewind from not playing media for a little bit to select the next
|
||||||
|
// chapter
|
||||||
|
return if (timeLeftInChapter < 10000L) {
|
||||||
|
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining")
|
||||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
Log.e(
|
||||||
|
tag,
|
||||||
|
"Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs"
|
||||||
|
)
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
nextChapterEndTimeMs
|
nextChapterEndTimeMs
|
||||||
@@ -205,17 +317,35 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resetChapterTimer() {
|
/**
|
||||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
* Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does
|
||||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
* not take place, make sure to set `isAutoSleepTimer` after calling this function.
|
||||||
vibrateFeedback()
|
*/
|
||||||
setSleepTimer(chapterEndTime, true)
|
private fun tryRewindAutoSleepTimer() {
|
||||||
play()
|
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||||
|
if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) {
|
||||||
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms"
|
||||||
|
)
|
||||||
|
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checks if the sleep timer should be reset. */
|
||||||
private fun checkShouldResetSleepTimer() {
|
private fun checkShouldResetSleepTimer() {
|
||||||
if (!sleepTimerRunning) {
|
if (sleepTimerRunning) {
|
||||||
|
// Reset the sleep timer if it has been running for at least 3 seconds or it is an end of
|
||||||
|
// chapter/track timer
|
||||||
|
if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) {
|
||||||
|
Log.d(tag, "Resetting running sleep timer")
|
||||||
|
vibrateFeedback()
|
||||||
|
setSleepTimer(sleepTimerLength)
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
if (sleepTimerFinishedAt <= 0L) return
|
if (sleepTimerFinishedAt <= 0L) return
|
||||||
|
|
||||||
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||||
@@ -226,51 +356,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically Rewind in the book if settings is enabled
|
// If timer was cleared by going negative on time, clear the sleep timer length so pressing
|
||||||
if (isAutoSleepTimer) {
|
// play allows playback to continue without the sleep timer continuously setting for 1 second.
|
||||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
if (sleepTimerLength == 1000L) {
|
||||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer")
|
||||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
sleepTimerFinishedAt = 0L
|
||||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
return
|
||||||
}
|
|
||||||
isFirstAutoSleepTimer = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically rewind in the book if settings are enabled
|
||||||
|
tryRewindAutoSleepTimer()
|
||||||
|
|
||||||
// Set sleep timer
|
// Set sleep timer
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
Log.d(tag, "Resetting stopped sleep timer")
|
||||||
if (sleepTimerLength == 0L) {
|
|
||||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
|
||||||
resetChapterTimer()
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
|
||||||
vibrateFeedback()
|
|
||||||
setSleepTimer(sleepTimerLength, false)
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
|
||||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
|
||||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
|
||||||
vibrateFeedback()
|
vibrateFeedback()
|
||||||
setSleepTimer(sleepTimerLength, false)
|
setSleepTimer(sleepTimerLength)
|
||||||
} else if (sleepTimerLength == 0L) {
|
play()
|
||||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
|
||||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
|
||||||
if (chapterEndTime != sleepTimerEndTime) {
|
|
||||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
|
||||||
vibrateFeedback()
|
|
||||||
setSleepTimer(chapterEndTime, true)
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2
|
||||||
|
* minute grace period after the timer ends or while media is playing.
|
||||||
|
*/
|
||||||
fun handleShake() {
|
fun handleShake() {
|
||||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) {
|
||||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||||
return
|
return
|
||||||
@@ -279,48 +389,63 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increases the sleep timer time.
|
||||||
|
* @param time Long - the time to increase the sleep timer by.
|
||||||
|
*/
|
||||||
fun increaseSleepTime(time: Long) {
|
fun increaseSleepTime(time: Long) {
|
||||||
Log.d(tag, "Increase Sleep time $time")
|
Log.d(tag, "Increase Sleep time $time")
|
||||||
if (!sleepTimerRunning) return
|
if (!sleepTimerRunning) return
|
||||||
|
|
||||||
|
// Increase the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||||
|
// and ensure it doesn't go over the duration of the current playback item
|
||||||
if (sleepTimerEndTime == 0L) {
|
if (sleepTimerEndTime == 0L) {
|
||||||
|
// Fixed length
|
||||||
sleepTimerLength += time
|
sleepTimerLength += time
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime())
|
||||||
} else {
|
} else {
|
||||||
val newSleepEndTime = sleepTimerEndTime + time
|
// Chapter end time
|
||||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
sleepTimerEndTime =
|
||||||
getDuration()
|
minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration())
|
||||||
} else {
|
|
||||||
newSleepEndTime
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(1F)
|
setVolume(1F)
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decreases the sleep timer time.
|
||||||
|
* @param time Long - the time to decrease the sleep timer by.
|
||||||
|
*/
|
||||||
fun decreaseSleepTime(time: Long) {
|
fun decreaseSleepTime(time: Long) {
|
||||||
Log.d(tag, "Decrease Sleep time $time")
|
Log.d(tag, "Decrease Sleep time $time")
|
||||||
if (!sleepTimerRunning) return
|
if (!sleepTimerRunning) return
|
||||||
|
|
||||||
|
// Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||||
|
// and ensure it doesn't go below 1 second
|
||||||
if (sleepTimerEndTime == 0L) {
|
if (sleepTimerEndTime == 0L) {
|
||||||
sleepTimerLength -= time
|
// Fixed length
|
||||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
sleepTimerLength = maxOf(sleepTimerLength - time, 1000L)
|
||||||
} else {
|
} else {
|
||||||
val newSleepEndTime = sleepTimerEndTime - time
|
// Chapter end time
|
||||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
sleepTimerEndTime =
|
||||||
// End sleep timer in 1 second
|
maxOf(
|
||||||
getCurrentTime() + 1000
|
sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(),
|
||||||
} else {
|
getCurrentTime() + 1000
|
||||||
newSleepEndTime
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setVolume(1F)
|
setVolume(1F)
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */
|
||||||
fun checkAutoSleepTimer() {
|
fun checkAutoSleepTimer() {
|
||||||
if (sleepTimerRunning) { // Sleep timer already running
|
if (sleepTimerRunning) { // Sleep timer already running
|
||||||
return
|
return
|
||||||
@@ -337,10 +462,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
|
|
||||||
val currentCalendar = Calendar.getInstance()
|
val currentCalendar = Calendar.getInstance()
|
||||||
|
|
||||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
// In cases where end time is before start time then we shift the time window forward or
|
||||||
|
// backward based on the current time.
|
||||||
// e.g. start time 22:00 and end time 06:00.
|
// e.g. start time 22:00 and end time 06:00.
|
||||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
// If current time is less than start time (e.g. 00:30) then start time will be the
|
||||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
// previous day.
|
||||||
|
// If current time is greater than start time (e.g. 23:00) then end time will be the
|
||||||
|
// next day.
|
||||||
if (endCalendar.before(startCalendar)) {
|
if (endCalendar.before(startCalendar)) {
|
||||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||||
@@ -349,49 +477,68 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
val isDuringAutoTime =
|
||||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)
|
||||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
|
||||||
|
|
||||||
// Automatically Rewind in the book if settings is enabled
|
// Determine whether to set the auto sleep timer or not
|
||||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
if (autoTimerDisabled) {
|
||||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
if (!isDuringAutoTime) {
|
||||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
// Check if sleep timer was disabled during the previous period and enable again
|
||||||
}
|
Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period")
|
||||||
isFirstAutoSleepTimer = false
|
autoTimerDisabled = false
|
||||||
|
|
||||||
// Set sleep timer
|
|
||||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
|
||||||
if (deviceSettings.sleepTimerLength == 0L) {
|
|
||||||
val chapterEndTimeMs = this.getChapterEndTime()
|
|
||||||
if (chapterEndTimeMs == null) {
|
|
||||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
|
||||||
} else {
|
|
||||||
isAutoSleepTimer = true
|
|
||||||
setSleepTimer(chapterEndTimeMs, true)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
isAutoSleepTimer = true
|
// Auto time is disabled, do not set sleep timer
|
||||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
Log.i(tag, "Auto sleep timer is disabled for this time period")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
isFirstAutoSleepTimer = true
|
if (isDuringAutoTime) {
|
||||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
// Start an auto sleep timer
|
||||||
|
val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||||
|
val currentMin = currentCalendar.get(Calendar.MINUTE)
|
||||||
|
Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin")
|
||||||
|
|
||||||
|
// Automatically rewind in the book if settings is enabled
|
||||||
|
tryRewindAutoSleepTimer()
|
||||||
|
|
||||||
|
// Set `isAutoSleepTimer` to true to indicate that the timer was set automatically
|
||||||
|
// and to not cause the timer to rewind
|
||||||
|
isAutoSleepTimer = true
|
||||||
|
setSleepTimer(deviceSettings.sleepTimerLength)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Not in auto sleep time period")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
/**
|
||||||
|
* Handles the media play event and checks if the sleep timer should be reset or set.
|
||||||
|
* @param playbackSessionId String - the playback session ID.
|
||||||
|
*/
|
||||||
|
fun handleMediaPlayEvent(playbackSessionId: String) {
|
||||||
// Check if the playback session has changed
|
// Check if the playback session has changed
|
||||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer
|
||||||
|
// will stay on and reset to 10 mins
|
||||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||||
checkShouldResetSleepTimer()
|
checkShouldResetSleepTimer()
|
||||||
} else {
|
} else {}
|
||||||
isFirstAutoSleepTimer = true
|
|
||||||
}
|
|
||||||
sleepTimerSessionId = playbackSessionId
|
sleepTimerSessionId = playbackSessionId
|
||||||
|
|
||||||
checkAutoSleepTimer()
|
checkAutoSleepTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the current sleep timer state to the client.
|
||||||
|
* Called when app resumes from background to sync UI state.
|
||||||
|
*/
|
||||||
|
fun sendCurrentSleepTimerState() {
|
||||||
|
if (sleepTimerRunning) {
|
||||||
|
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
|
||||||
|
} else {
|
||||||
|
// No timer running - send 0 to clear any stale UI state
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,76 +36,97 @@ object MediaEventManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}"
|
||||||
|
)
|
||||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}"
|
||||||
|
)
|
||||||
addSyncEvent("Sync", mediaProgress, description)
|
addSyncEvent("Sync", mediaProgress, description)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
private fun addSyncEvent(
|
||||||
|
eventName: String,
|
||||||
|
mediaProgress: MediaProgressWrapper,
|
||||||
|
description: String
|
||||||
|
) {
|
||||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||||
if (mediaItemHistory == null) {
|
if (mediaItemHistory == null) {
|
||||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
Log.w(
|
||||||
|
tag,
|
||||||
|
"addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaItemEvent = MediaItemEvent(
|
val mediaItemEvent =
|
||||||
name = eventName,
|
MediaItemEvent(
|
||||||
type = "Sync",
|
name = eventName,
|
||||||
description = description,
|
type = "Sync",
|
||||||
currentTime = mediaProgress.currentTime,
|
description = description,
|
||||||
serverSyncAttempted = false,
|
currentTime = mediaProgress.currentTime,
|
||||||
serverSyncSuccess = null,
|
serverSyncAttempted = false,
|
||||||
serverSyncMessage = null,
|
serverSyncSuccess = null,
|
||||||
timestamp = System.currentTimeMillis()
|
serverSyncMessage = null,
|
||||||
)
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
mediaItemHistory.events.add(mediaItemEvent)
|
mediaItemHistory.events.add(mediaItemEvent)
|
||||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||||
|
|
||||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
private fun addPlaybackEvent(
|
||||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
eventName: String,
|
||||||
|
playbackSession: PlaybackSession,
|
||||||
|
syncResult: SyncResult?
|
||||||
|
) {
|
||||||
|
val mediaItemHistory =
|
||||||
|
getMediaItemHistoryMediaItem(playbackSession.mediaItemId)
|
||||||
|
?: createMediaItemHistoryForSession(playbackSession)
|
||||||
|
|
||||||
val mediaItemEvent = MediaItemEvent(
|
val mediaItemEvent =
|
||||||
name = eventName,
|
MediaItemEvent(
|
||||||
type = "Playback",
|
name = eventName,
|
||||||
description = "",
|
type = "Playback",
|
||||||
currentTime = playbackSession.currentTime,
|
description = "",
|
||||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
currentTime = playbackSession.currentTime,
|
||||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||||
timestamp = System.currentTimeMillis()
|
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||||
)
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
mediaItemHistory.events.add(mediaItemEvent)
|
mediaItemHistory.events.add(mediaItemEvent)
|
||||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||||
|
|
||||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? {
|
||||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory {
|
||||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
val libraryItemId = playbackSession.libraryItemId ?: ""
|
||||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
val episodeId: String? = playbackSession.episodeId
|
||||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
|
||||||
return MediaItemHistory(
|
return MediaItemHistory(
|
||||||
id = playbackSession.mediaItemId,
|
id = playbackSession.mediaItemId,
|
||||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||||
libraryItemId,
|
libraryItemId,
|
||||||
episodeId,
|
episodeId,
|
||||||
isLocalOnly,
|
false, // local-only items are not supported
|
||||||
playbackSession.serverConnectionConfigId,
|
playbackSession.serverConnectionConfigId,
|
||||||
playbackSession.serverAddress,
|
playbackSession.serverAddress,
|
||||||
playbackSession.userId,
|
playbackSession.userId,
|
||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
events = mutableListOf())
|
events = mutableListOf()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,24 +9,38 @@ import com.audiobookshelf.app.device.DeviceManager
|
|||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||||
val tag = "MediaManager"
|
val tag = "MediaManager"
|
||||||
|
|
||||||
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||||
private var selectedLibraryItems = mutableListOf<LibraryItem>()
|
|
||||||
private var selectedLibraryId = ""
|
private var cachedLibraryAuthors : MutableMap<String, MutableMap<String, LibraryAuthorItem>> = hashMapOf()
|
||||||
|
private var cachedLibraryAuthorItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||||
|
private var cachedLibraryAuthorSeriesItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||||
|
private var cachedLibrarySeries : MutableMap<String, List<LibrarySeriesItem>> = hashMapOf()
|
||||||
|
private var cachedLibrarySeriesItem : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||||
|
private var cachedLibraryCollections : MutableMap<String, MutableMap<String, LibraryCollection>> = hashMapOf()
|
||||||
|
private var cachedLibraryRecentShelves : MutableMap<String, MutableList<LibraryShelfType>> = hashMapOf()
|
||||||
|
private var cachedLibraryDiscovery : MutableMap<String, MutableList<LibraryItem>> = hashMapOf()
|
||||||
|
private var cachedLibraryPodcasts : MutableMap<String, MutableMap<String, LibraryItem>> = hashMapOf()
|
||||||
|
private var isLibraryPodcastsCached : MutableMap<String, Boolean> = hashMapOf()
|
||||||
|
var allLibraryPersonalizationsDone : Boolean = false
|
||||||
|
private var libraryPersonalizationsDone : Int = 0
|
||||||
|
|
||||||
private var selectedPodcast:Podcast? = null
|
private var selectedPodcast:Podcast? = null
|
||||||
private var selectedLibraryItemId:String? = null
|
private var selectedLibraryItemId:String? = null
|
||||||
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||||
private var serverLibraryCategories = listOf<LibraryCategory>()
|
|
||||||
private var serverConfigIdUsed:String? = null
|
private var serverConfigIdUsed:String? = null
|
||||||
private var serverConfigLastPing:Long = 0L
|
private var serverConfigLastPing:Long = 0L
|
||||||
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
@@ -35,10 +49,49 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
|
|
||||||
var userSettingsPlaybackRate:Float? = null
|
var userSettingsPlaybackRate:Float? = null
|
||||||
|
|
||||||
|
// Android Auto browses on the media browser service main thread, so the server pings
|
||||||
|
// and authorize call must not run there or they block it and trigger an ANR
|
||||||
|
private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
// Android Auto re-requests the browse root every ~2s and multiple clients do so at once,
|
||||||
|
// so overlapping loads are coalesced into one to avoid racing on the shared server state
|
||||||
|
private var isLoadingAndroidAutoItems = false
|
||||||
|
private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>()
|
||||||
|
private val androidAutoLoadLock = Any()
|
||||||
|
|
||||||
fun getIsLibrary(id:String) : Boolean {
|
fun getIsLibrary(id:String) : Boolean {
|
||||||
return serverLibraries.find { it.id == id } != null
|
return serverLibraries.find { it.id == id } != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there is discovery shelf for [libraryId]
|
||||||
|
* If personalized shelves are not yet populated for library then populate
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun getHasDiscovery(libraryId: String) : Boolean {
|
||||||
|
if (cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||||
|
if (cachedLibraryDiscovery[libraryId]!!.isNotEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
populatePersonalizedDataForLibrary(libraryId){}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibrary(id:String) : Library? {
|
||||||
|
return serverLibraries.find { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add [libraryItem] to [serverLibraryItems] if it is not already added
|
||||||
|
*/
|
||||||
|
private fun addServerLibrary(libraryItem: LibraryItem) {
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getSavedPlaybackRate():Float {
|
fun getSavedPlaybackRate():Float {
|
||||||
if (userSettingsPlaybackRate != null) {
|
if (userSettingsPlaybackRate != null) {
|
||||||
return userSettingsPlaybackRate ?: 1f
|
return userSettingsPlaybackRate ?: 1f
|
||||||
@@ -91,19 +144,31 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkResetServerItems() {
|
fun checkResetServerItems():Boolean {
|
||||||
// When opening android auto need to check if still connected to server
|
// When opening android auto need to check if still connected to server
|
||||||
// and reset any server data already set
|
// and reset any server data already set
|
||||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||||
|
|
||||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) {
|
||||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
serverLibraryCategories = listOf()
|
|
||||||
serverLibraries = listOf()
|
serverLibraries = listOf()
|
||||||
serverLibraryItems = mutableListOf()
|
serverLibraryItems = mutableListOf()
|
||||||
selectedLibraryItems = mutableListOf()
|
cachedLibraryAuthors = hashMapOf()
|
||||||
selectedLibraryId = ""
|
cachedLibraryAuthorItems = hashMapOf()
|
||||||
|
cachedLibraryAuthorSeriesItems = hashMapOf()
|
||||||
|
cachedLibrarySeries = hashMapOf()
|
||||||
|
cachedLibrarySeriesItem = hashMapOf()
|
||||||
|
cachedLibraryCollections = hashMapOf()
|
||||||
|
cachedLibraryRecentShelves = hashMapOf()
|
||||||
|
cachedLibraryDiscovery = hashMapOf()
|
||||||
|
cachedLibraryPodcasts = hashMapOf()
|
||||||
|
isLibraryPodcastsCached = hashMapOf()
|
||||||
|
serverItemsInProgress = listOf()
|
||||||
|
allLibraryPersonalizationsDone = false
|
||||||
|
libraryPersonalizationsDone = 0
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
@@ -120,28 +185,457 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
/**
|
||||||
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
* Load personalized shelves from server for all libraries.
|
||||||
cb(selectedLibraryItems)
|
* [cb] resolves when all libraries are processed
|
||||||
|
*/
|
||||||
|
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
||||||
|
val remaining = AtomicInteger(serverLibraries.size)
|
||||||
|
|
||||||
|
serverLibraries.forEach { lib ->
|
||||||
|
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||||
|
populatePersonalizedDataForLibrary(lib.id) {
|
||||||
|
Log.d(tag, "Loaded personalization for library ${lib.name}")
|
||||||
|
if (remaining.decrementAndGet() == 0) {
|
||||||
|
Log.d(tag, "Finished loading all library personalization data")
|
||||||
|
allLibraryPersonalizationsDone = true
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get personalized shelves from server for selected [libraryId].
|
||||||
|
* Populates [cachedLibraryRecentShelves] and [cachedLibraryDiscovery].
|
||||||
|
*/
|
||||||
|
private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) {
|
||||||
|
apiHandler.getLibraryPersonalized(libraryId) { shelves ->
|
||||||
|
Log.d(tag, "populatePersonalizedDataForLibrary $libraryId")
|
||||||
|
if (shelves === null) return@getLibraryPersonalized
|
||||||
|
shelves.map { shelf ->
|
||||||
|
Log.d(tag, "$shelf")
|
||||||
|
if (shelf.type == "book") {
|
||||||
|
if (shelf.id == "continue-listening") return@map
|
||||||
|
else if (shelf.id == "listen-again") return@map
|
||||||
|
else if (shelf.id == "recently-added") {
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||||
|
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (shelf.id == "discover") {
|
||||||
|
if (!cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||||
|
cachedLibraryDiscovery[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
(shelf as LibraryShelfBookEntity).entities?.map {
|
||||||
|
cachedLibraryDiscovery[libraryId]!!.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (shelf.id == "continue-reading") return@map
|
||||||
|
else if (shelf.id == "continue-series") return@map
|
||||||
|
shelf as LibraryShelfBookEntity
|
||||||
|
} else if (shelf.type == "series") {
|
||||||
|
if (shelf.id == "recent-series") {
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||||
|
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (shelf.type == "episode") {
|
||||||
|
if (shelf.id == "continue-listening") return@map
|
||||||
|
else if (shelf.id == "listen-again") return@map
|
||||||
|
else if (shelf.id == "newest-episodes") {
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||||
|
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||||
|
}
|
||||||
|
|
||||||
|
val podcastLibraryItemIds = mutableListOf<String>()
|
||||||
|
(shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem ->
|
||||||
|
if (!podcastLibraryItemIds.contains(libraryItem.id)) {
|
||||||
|
podcastLibraryItemIds.add(libraryItem.id)
|
||||||
|
loadPodcastItem(libraryItem.libraryId, libraryItem.id) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (shelf.type == "podcast") {
|
||||||
|
if (shelf.id == "recently-added"){
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||||
|
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (shelf.id == "discover"){
|
||||||
|
return@map
|
||||||
|
}
|
||||||
|
} else if (shelf.type =="authors") {
|
||||||
|
if (shelf.id == "newest-authors") {
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||||
|
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE")
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns podcasts for selected library.
|
||||||
|
* If data is not found from local cache it is loaded from server
|
||||||
|
*/
|
||||||
|
fun loadLibraryPodcasts(libraryId:String, cb: (List<LibraryItem>?) -> Unit) {
|
||||||
|
// Without this there is possibility that only recent podcasts get loaded
|
||||||
|
// Loading recent podcasts will also create cachedLibraryPodcasts entry for library
|
||||||
|
if (!isLibraryPodcastsCached.containsKey(libraryId)) {
|
||||||
|
isLibraryPodcastsCached[libraryId] = false
|
||||||
|
}
|
||||||
|
// Ensure that there is map for library
|
||||||
|
if (!cachedLibraryPodcasts.containsKey(libraryId)) {
|
||||||
|
cachedLibraryPodcasts[libraryId] = mutableMapOf()
|
||||||
|
}
|
||||||
|
if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) {
|
||||||
|
Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId")
|
||||||
|
cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title })
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
if (libraryItemsWithAudio.isNotEmpty()) {
|
|
||||||
selectedLibraryId = libraryId
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedLibraryItems = mutableListOf()
|
|
||||||
libraryItemsWithAudio.forEach { libraryItem ->
|
libraryItemsWithAudio.forEach { libraryItem ->
|
||||||
selectedLibraryItems.add(libraryItem)
|
cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem)
|
||||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
serverLibraryItems.add(libraryItem)
|
serverLibraryItems.add(libraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isLibraryPodcastsCached[libraryId] = true
|
||||||
|
Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId")
|
||||||
|
cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns series with audio books from selected library.
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibrarySeriesWithAudio(libraryId:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||||
|
// Check "cache" first
|
||||||
|
if (cachedLibrarySeries.containsKey(libraryId)) {
|
||||||
|
Log.d(tag, "Series with audio found from cache | Library $libraryId ")
|
||||||
|
cb(cachedLibrarySeries[libraryId] as List<LibrarySeriesItem>)
|
||||||
|
} else {
|
||||||
|
apiHandler.getLibrarySeries(libraryId) { seriesItems ->
|
||||||
|
Log.d(tag, "Series with audio loaded from server | Library $libraryId")
|
||||||
|
val seriesItemsWithAudio = seriesItems.filter { si -> si.audiobookCount > 0 }
|
||||||
|
|
||||||
|
cachedLibrarySeries[libraryId] = seriesItemsWithAudio
|
||||||
|
|
||||||
|
cb(seriesItemsWithAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns series with audiobooks from selected library using filter for paging.
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibrarySeriesWithAudio(libraryId:String, seriesFilter:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||||
|
// Check "cache" first
|
||||||
|
if (!cachedLibrarySeries.containsKey(libraryId)) {
|
||||||
|
loadLibrarySeriesWithAudio(libraryId) {}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Series with audio found from cache | Library $libraryId ")
|
||||||
|
}
|
||||||
|
val seriesWithBooks = cachedLibrarySeries[libraryId]!!.filter { ls -> ls.title.uppercase().startsWith(seriesFilter) }.toList()
|
||||||
|
cb(seriesWithBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts books in series. Assumes that sequence is main.minor
|
||||||
|
*/
|
||||||
|
private fun sortSeriesBooks(seriesBooks: List<LibraryItem>) : List<LibraryItem> {
|
||||||
|
val sortingLogic = compareBy<LibraryItem> { it.seriesSequenceParts[0].length }
|
||||||
|
.thenBy { it.seriesSequenceParts[0].ifEmpty { "" } }
|
||||||
|
.thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length }
|
||||||
|
.thenBy { it.seriesSequenceParts.getOrElse(1) { "" } }
|
||||||
|
return seriesBooks.sortedWith(sortingLogic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns books for series from library.
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
// Check "cache" first
|
||||||
|
if (!cachedLibrarySeriesItem.containsKey(libraryId)) {
|
||||||
|
cachedLibrarySeriesItem[libraryId] = hashMapOf()
|
||||||
|
}
|
||||||
|
if (cachedLibrarySeriesItem[libraryId]!!.containsKey(seriesId)) {
|
||||||
|
Log.d(tag, "Items for series $seriesId found from cache | Library $libraryId")
|
||||||
|
cachedLibrarySeriesItem[libraryId]!![seriesId]?.let { cb(it) }
|
||||||
|
} else {
|
||||||
|
apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems ->
|
||||||
|
Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId")
|
||||||
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
|
|
||||||
|
val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio)
|
||||||
|
cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio
|
||||||
|
|
||||||
|
sortedLibraryItemsWithAudio.forEach { libraryItem ->
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(sortedLibraryItemsWithAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns authors with books from library.
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadAuthorsWithBooks(libraryId:String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||||
|
// Check "cache" first
|
||||||
|
if (cachedLibraryAuthors.containsKey(libraryId)) {
|
||||||
|
Log.d(tag, "Authors with books found from cache | Library $libraryId ")
|
||||||
|
cb(cachedLibraryAuthors[libraryId]!!.values.toList())
|
||||||
|
} else {
|
||||||
|
// Fetch data from server and add it to local "cache"
|
||||||
|
apiHandler.getLibraryAuthors(libraryId) { authorItems ->
|
||||||
|
Log.d(tag, "Authors with books loaded from server | Library $libraryId ")
|
||||||
|
// TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so
|
||||||
|
var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 }
|
||||||
|
authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name }
|
||||||
|
// Ensure that there is map for library
|
||||||
|
cachedLibraryAuthors[libraryId] = mutableMapOf()
|
||||||
|
// Cache authors
|
||||||
|
authorItemsWithBooks.forEach {
|
||||||
|
if (!cachedLibraryAuthors[libraryId]!!.containsKey(it.id)) {
|
||||||
|
cachedLibraryAuthors[libraryId]!![it.id] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(authorItemsWithBooks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns authors with books from selected library using filter for paging.
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadAuthorsWithBooks(libraryId:String, authorFilter: String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||||
|
// Check "cache" first
|
||||||
|
if (cachedLibraryAuthors.containsKey(libraryId)) {
|
||||||
|
Log.d(tag, "Authors with books found from cache | Library $libraryId ")
|
||||||
|
} else {
|
||||||
|
loadAuthorsWithBooks(libraryId) {}
|
||||||
|
}
|
||||||
|
val authorsWithBooks = cachedLibraryAuthors[libraryId]!!.values.filter { lai -> lai.name.uppercase().startsWith(authorFilter) }.toList()
|
||||||
|
cb(authorsWithBooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns audiobooks for author from library
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadAuthorBooksWithAudio(libraryId:String, authorId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
// Ensure that there is map for library
|
||||||
|
if (!cachedLibraryAuthorItems.containsKey(libraryId)) {
|
||||||
|
cachedLibraryAuthorItems[libraryId] = mutableMapOf()
|
||||||
|
}
|
||||||
|
// Check "cache" first
|
||||||
|
if (cachedLibraryAuthorItems[libraryId]!!.containsKey(authorId)) {
|
||||||
|
Log.d(tag, "Items for author $authorId found from cache | Library $libraryId")
|
||||||
|
cachedLibraryAuthorItems[libraryId]!![authorId]?.let { cb(it) }
|
||||||
|
} else {
|
||||||
|
apiHandler.getLibraryItemsFromAuthor(libraryId, authorId) { libraryItems ->
|
||||||
|
Log.d(tag, "Items for author $authorId loaded from server | Library $libraryId")
|
||||||
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
|
|
||||||
|
cachedLibraryAuthorItems[libraryId]!![authorId] = libraryItemsWithAudio
|
||||||
|
|
||||||
|
libraryItemsWithAudio.forEach { libraryItem ->
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cb(libraryItemsWithAudio)
|
cb(libraryItemsWithAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns audiobooks for author from specified series within library
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadAuthorSeriesBooksWithAudio(libraryId:String, authorId:String, seriesId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
val authorSeriesKey = "$authorId|$seriesId"
|
||||||
|
// Ensure that there is map for library
|
||||||
|
if (!cachedLibraryAuthorSeriesItems.containsKey(libraryId)) {
|
||||||
|
cachedLibraryAuthorSeriesItems[libraryId] = mutableMapOf()
|
||||||
|
}
|
||||||
|
// Check "cache" first
|
||||||
|
if (cachedLibraryAuthorSeriesItems[libraryId]!!.containsKey(authorSeriesKey)) {
|
||||||
|
Log.d(tag, "Items for series $seriesId with author $authorId found from cache | Library $libraryId")
|
||||||
|
cachedLibraryAuthorSeriesItems[libraryId]!![authorSeriesKey]?.let { cb(it) }
|
||||||
|
} else {
|
||||||
|
apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems ->
|
||||||
|
Log.d(tag, "Items for series $seriesId with author $authorId loaded from server | Library $libraryId")
|
||||||
|
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||||
|
if (!cachedLibraryAuthors[libraryId]!!.containsKey(authorId)) {
|
||||||
|
Log.d(tag, "Author data is missing")
|
||||||
|
}
|
||||||
|
val authorName = cachedLibraryAuthors[libraryId]!![authorId]?.name ?: ""
|
||||||
|
Log.d(tag, "Using author name: $authorName")
|
||||||
|
val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 }
|
||||||
|
|
||||||
|
val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio)
|
||||||
|
cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio
|
||||||
|
|
||||||
|
sortedLibraryItemsWithAudio.forEach { libraryItem ->
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(sortedLibraryItemsWithAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns collections with audiobooks from library
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibraryCollectionsWithAudio(libraryId:String, cb: (List<LibraryCollection>) -> Unit) {
|
||||||
|
if (cachedLibraryCollections.containsKey(libraryId)) {
|
||||||
|
Log.d(tag, "Collections with books found from cache | Library $libraryId ")
|
||||||
|
cb(cachedLibraryCollections[libraryId]!!.values.toList())
|
||||||
|
} else {
|
||||||
|
apiHandler.getLibraryCollections(libraryId) { libraryCollections ->
|
||||||
|
Log.d(tag, "Collections with books loaded from server | Library $libraryId ")
|
||||||
|
val libraryCollectionsWithAudio = libraryCollections.filter { lc -> lc.audiobookCount > 0 }
|
||||||
|
|
||||||
|
// Cache collections
|
||||||
|
cachedLibraryCollections[libraryId] = hashMapOf()
|
||||||
|
libraryCollectionsWithAudio.forEach {
|
||||||
|
if (!cachedLibraryCollections[libraryId]!!.containsKey(it.id)) {
|
||||||
|
cachedLibraryCollections[libraryId]!![it.id] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(libraryCollectionsWithAudio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns audiobooks for collection from library
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibraryCollectionBooksWithAudio(libraryId: String, collectionId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
if (!cachedLibraryCollections.containsKey(libraryId)) {
|
||||||
|
loadLibraryCollectionsWithAudio(libraryId) {}
|
||||||
|
}
|
||||||
|
Log.d(tag, "Trying to find collection $collectionId items from from cache | Library $libraryId ")
|
||||||
|
if ( cachedLibraryCollections[libraryId]!!.containsKey(collectionId)) {
|
||||||
|
val libraryCollectionBookswithAudio = cachedLibraryCollections[libraryId]!![collectionId]?.books
|
||||||
|
libraryCollectionBookswithAudio?.forEach { libraryItem ->
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(libraryCollectionBookswithAudio as List<LibraryItem>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns audiobooks from discovery shelf for [libraryId]
|
||||||
|
* If data is not found from local cache then it will be fetched from server
|
||||||
|
*/
|
||||||
|
fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
if (!cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||||
|
cb(listOf())
|
||||||
|
}
|
||||||
|
val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() }
|
||||||
|
libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) }
|
||||||
|
cb(libraryItemsWithAudio as List<LibraryItem>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns recent shelves for [libraryId]
|
||||||
|
* If data is not shelves are found returns empty list
|
||||||
|
*/
|
||||||
|
fun getLibraryRecentShelfs(libraryId: String, cb: (List<LibraryShelfType>) -> Unit) {
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId")
|
||||||
|
cb(listOf())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cb(cachedLibraryRecentShelves[libraryId] as List<LibraryShelfType>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns recent shelf by [type] for [libraryId]
|
||||||
|
* If shelf is not found returns null
|
||||||
|
*/
|
||||||
|
fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) {
|
||||||
|
Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type")
|
||||||
|
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||||
|
cb(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (shelf in cachedLibraryRecentShelves[libraryId]!!) {
|
||||||
|
if (shelf.type == type.lowercase()) {
|
||||||
|
cb(shelf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads podcasts for newest episodes shelf
|
||||||
|
*/
|
||||||
|
private fun loadPodcastItem(libraryId: String, libraryItemId: String, cb: (LibraryItem?) -> Unit) {
|
||||||
|
// Ensure that there is map for library
|
||||||
|
if (!cachedLibraryPodcasts.containsKey(libraryId)) {
|
||||||
|
cachedLibraryPodcasts[libraryId] = mutableMapOf()
|
||||||
|
}
|
||||||
|
if (cachedLibraryPodcasts[libraryId]!!.containsKey(libraryItemId)) {
|
||||||
|
Log.d(tag, "loadPodcastItem: Podcast found from cache | Library $libraryItemId ")
|
||||||
|
cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId))
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "loadPodcastItem: Calling getLibraryItem $libraryItemId")
|
||||||
|
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||||
|
if (libraryItem !== null) {
|
||||||
|
Log.d(tag, "loadPodcastItem: Got library item ${libraryItem.id} ${libraryItem.media.metadata.title}")
|
||||||
|
val podcast = libraryItem.media as Podcast
|
||||||
|
podcast.episodes?.forEach { podcastEpisode ->
|
||||||
|
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItem, podcastEpisode)
|
||||||
|
}
|
||||||
|
cachedLibraryPodcasts[libraryId]?.set(libraryItemId, libraryItem)
|
||||||
|
cb(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||||
if (libraryItemId.startsWith("local")) {
|
if (libraryItemId.startsWith("local")) {
|
||||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||||
@@ -187,8 +681,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
selectedLibraryItemId = libraryItemWrapper.id
|
selectedLibraryItemId = libraryItemWrapper.id
|
||||||
selectedPodcast = podcast
|
selectedPodcast = podcast
|
||||||
|
val episodes = podcast.episodes?.sortedByDescending { it.publishedAt }
|
||||||
val children = podcast.episodes?.map { podcastEpisode ->
|
val children = episodes?.map { podcastEpisode ->
|
||||||
|
|
||||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||||
|
|
||||||
@@ -209,13 +703,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads libraries for selected server with stats
|
||||||
|
*/
|
||||||
private fun loadLibraries(cb: (List<Library>) -> Unit) {
|
private fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||||
if (serverLibraries.isNotEmpty()) {
|
if (serverLibraries.isNotEmpty()) {
|
||||||
cb(serverLibraries)
|
cb(serverLibraries)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getLibraries {
|
apiHandler.getLibraries { loadedLibraries ->
|
||||||
serverLibraries = it
|
serverLibraries = loadedLibraries
|
||||||
cb(it)
|
cb(serverLibraries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +743,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
return mediaProgress
|
return mediaProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
// Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from
|
||||||
|
// onLoadChildren on the media browser service main thread, and the pings/authorize below
|
||||||
|
// are network calls, so runBlocking here caused ANRs while browsing in Android Auto
|
||||||
|
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch {
|
||||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
@@ -329,9 +829,48 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun initializeInProgressItems(cb: () -> Unit) {
|
||||||
|
Log.d(tag, "Initializing inprogress items")
|
||||||
|
|
||||||
|
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||||
|
itemsInProgress.forEach {
|
||||||
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (it.episode != null) {
|
||||||
|
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d(tag, "Initializing inprogress items done")
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||||
Log.d(tag, "Load android auto items")
|
Log.d(tag, "Load android auto items")
|
||||||
|
|
||||||
|
// Coalesce overlapping calls into a single load, every caller still gets its callback
|
||||||
|
synchronized(androidAutoLoadLock) {
|
||||||
|
pendingAndroidAutoLoadCallbacks.add(cb)
|
||||||
|
if (isLoadingAndroidAutoItems) {
|
||||||
|
Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoadingAndroidAutoItems = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val onLoadFinished = {
|
||||||
|
val callbacks: List<() -> Unit>
|
||||||
|
synchronized(androidAutoLoadLock) {
|
||||||
|
isLoadingAndroidAutoItems = false
|
||||||
|
callbacks = pendingAndroidAutoLoadCallbacks.toList()
|
||||||
|
pendingAndroidAutoLoadCallbacks.clear()
|
||||||
|
}
|
||||||
|
callbacks.forEach { it() }
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any valid server connection if not use locally downloaded books
|
// Check if any valid server connection if not use locally downloaded books
|
||||||
checkSetValidServerConnectionConfig { isConnected ->
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
@@ -341,30 +880,73 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
loadLibraries { libraries ->
|
loadLibraries { libraries ->
|
||||||
if (libraries.isEmpty()) {
|
if (libraries.isEmpty()) {
|
||||||
Log.w(tag, "No libraries returned from server request")
|
Log.w(tag, "No libraries returned from server request")
|
||||||
cb()
|
onLoadFinished()
|
||||||
} else {
|
} else {
|
||||||
val library = libraries[0]
|
onLoadFinished() // Fully loaded
|
||||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
|
||||||
|
|
||||||
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
|
||||||
itemsInProgress.forEach {
|
|
||||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
|
||||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
|
||||||
serverLibraryItems.add(libraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (it.episode != null) {
|
|
||||||
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cb() // Fully loaded
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Not connected to server
|
} else { // Not connected to server
|
||||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||||
cb()
|
onLoadFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles search requests.
|
||||||
|
* Searches from books, series and authors
|
||||||
|
*/
|
||||||
|
suspend fun doSearch(libraryId: String, queryString: String) : Map<String, List<MediaBrowserCompat.MediaItem>> {
|
||||||
|
return suspendCoroutine {
|
||||||
|
apiHandler.getSearchResults(libraryId, queryString) { searchResult ->
|
||||||
|
Log.d(tag, "searchLocalCache: $searchResult")
|
||||||
|
// Nothing found from server
|
||||||
|
if (searchResult === null) {
|
||||||
|
it.resume(mapOf())
|
||||||
|
return@getSearchResults
|
||||||
|
}
|
||||||
|
|
||||||
|
val foundItems: MutableMap<String, List<MediaBrowserCompat.MediaItem>> = mutableMapOf()
|
||||||
|
|
||||||
|
val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId }
|
||||||
|
|
||||||
|
// Books
|
||||||
|
if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) {
|
||||||
|
Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books")
|
||||||
|
val children = searchResult.book!!.filter { it.libraryItem.checkHasTracks() }.map { bookResult ->
|
||||||
|
val libraryItem = bookResult.libraryItem
|
||||||
|
|
||||||
|
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||||
|
serverLibraryItems.add(libraryItem)
|
||||||
|
}
|
||||||
|
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||||
|
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||||
|
val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})")
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
foundItems["book"] = children
|
||||||
|
}
|
||||||
|
if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) {
|
||||||
|
Log.d(tag, "onSearch: found ${searchResult.series!!.size} series")
|
||||||
|
val children = searchResult.series!!.map { seriesResult ->
|
||||||
|
val seriesItem = seriesResult.series
|
||||||
|
seriesItem.books = seriesResult.books as MutableList<LibraryItem>
|
||||||
|
val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})")
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
foundItems["series"] = children
|
||||||
|
}
|
||||||
|
if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) {
|
||||||
|
Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors")
|
||||||
|
val children = searchResult.authors!!.map { authorItem ->
|
||||||
|
val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})")
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
}
|
||||||
|
foundItems["authors"] = children
|
||||||
|
}
|
||||||
|
|
||||||
|
it.resume(foundItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,41 +8,49 @@ import com.audiobookshelf.app.data.MediaProgress
|
|||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
data class MediaProgressSyncData(
|
data class MediaProgressSyncData(
|
||||||
var timeListened:Long, // seconds
|
var timeListened: Long, // seconds
|
||||||
var duration:Double, // seconds
|
var duration: Double, // seconds
|
||||||
var currentTime:Double // seconds
|
var currentTime: Double // seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SyncResult(
|
data class SyncResult(
|
||||||
var serverSyncAttempted:Boolean,
|
var serverSyncAttempted: Boolean,
|
||||||
var serverSyncSuccess:Boolean?,
|
var serverSyncSuccess: Boolean?,
|
||||||
var serverSyncMessage:String?
|
var serverSyncMessage: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
class MediaProgressSyncer(
|
||||||
|
val playerNotificationService: PlayerNotificationService,
|
||||||
|
private val apiHandler: ApiHandler
|
||||||
|
) {
|
||||||
private val tag = "MediaProgressSync"
|
private val tag = "MediaProgressSync"
|
||||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||||
|
|
||||||
private var listeningTimerTask: TimerTask? = null
|
private var listeningTimerTask: TimerTask? = null
|
||||||
var listeningTimerRunning:Boolean = false
|
var listeningTimerRunning: Boolean = false
|
||||||
|
|
||||||
private var lastSyncTime:Long = 0
|
private var lastSyncTime: Long = 0
|
||||||
private var failedSyncs:Int = 0
|
private var failedSyncs: Int = 0
|
||||||
|
|
||||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
|
|
||||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
private val currentDisplayTitle
|
||||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
val currentIsLocal
|
||||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
get() = currentPlaybackSession?.isLocal == true
|
||||||
|
val currentSessionId
|
||||||
|
get() = currentPlaybackSession?.id ?: ""
|
||||||
|
private val currentPlaybackDuration
|
||||||
|
get() = currentPlaybackSession?.duration ?: 0.0
|
||||||
|
|
||||||
fun start(playbackSession:PlaybackSession) {
|
fun start(playbackSession: PlaybackSession) {
|
||||||
if (listeningTimerRunning) {
|
if (listeningTimerRunning) {
|
||||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||||
if (playbackSession.id != currentSessionId) {
|
if (playbackSession.id != currentSessionId) {
|
||||||
@@ -62,40 +70,48 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
listeningTimerRunning = true
|
listeningTimerRunning = true
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
currentPlaybackSession = playbackSession.clone()
|
currentPlaybackSession = playbackSession.clone()
|
||||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}"
|
||||||
|
)
|
||||||
|
|
||||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
listeningTimerTask =
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
Handler(Looper.getMainLooper()).post() {
|
||||||
// Set auto sleep timer if enabled and within start/end time
|
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
// Set auto sleep timer if enabled and within start/end time
|
||||||
|
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||||
|
|
||||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
// Only sync with server on unmetered connection every 15s OR sync with server if
|
||||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
// last sync time is >= 60s
|
||||||
|
val shouldSyncServer =
|
||||||
|
PlayerNotificationService.isUnmeteredNetwork ||
|
||||||
|
System.currentTimeMillis() - lastSyncTime >=
|
||||||
|
METERED_CONNECTION_SYNC_INTERVAL
|
||||||
|
|
||||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
if (currentTime > 0) {
|
if (currentTime > 0) {
|
||||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||||
Log.d(tag, "Sync complete")
|
Log.d(tag, "Sync complete")
|
||||||
|
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(playbackSession:PlaybackSession) {
|
fun play(playbackSession: PlaybackSession) {
|
||||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||||
MediaEventManager.playEvent(playbackSession)
|
MediaEventManager.playEvent(playbackSession)
|
||||||
|
|
||||||
start(playbackSession)
|
start(playbackSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
fun stop(shouldSync: Boolean? = true, cb: () -> Unit) {
|
||||||
if (!listeningTimerRunning) {
|
if (!listeningTimerRunning) {
|
||||||
reset()
|
reset()
|
||||||
return cb()
|
return cb()
|
||||||
@@ -106,7 +122,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
listeningTimerRunning = false
|
listeningTimerRunning = false
|
||||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
val currentTime =
|
||||||
|
if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||||
sync(true, currentTime) { syncResult ->
|
sync(true, currentTime) { syncResult ->
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
@@ -192,17 +209,21 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Currently unused
|
||||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
it.updatedAt = mediaProgress.lastUpdate
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
it.currentTime = mediaProgress.currentTime
|
it.currentTime = mediaProgress.currentTime
|
||||||
|
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
MediaEventManager.syncEvent(
|
||||||
|
mediaProgress,
|
||||||
|
"Received from server get media progress request while playback session open"
|
||||||
|
)
|
||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) {
|
||||||
if (lastSyncTime <= 0) {
|
if (lastSyncTime <= 0) {
|
||||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||||
return cb(null)
|
return cb(null)
|
||||||
@@ -214,11 +235,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime)
|
||||||
currentPlaybackSession?.syncData(syncData)
|
currentPlaybackSession?.syncData(syncData)
|
||||||
|
|
||||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
Log.e(
|
||||||
|
tag,
|
||||||
|
"Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}"
|
||||||
|
)
|
||||||
return cb(null)
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,11 +250,7 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
|
|
||||||
// Save playback session to db (server linked sessions only)
|
// Save playback session to db (server linked sessions only)
|
||||||
// Sessions are removed once successfully synced with the server
|
// Sessions are removed once successfully synced with the server
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) }
|
||||||
if (!it.isLocalLibraryItemOnly) {
|
|
||||||
DeviceManager.dbManager.savePlaybackSession(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentIsLocal) {
|
if (currentIsLocal) {
|
||||||
// Save local progress sync
|
// Save local progress sync
|
||||||
@@ -238,36 +258,50 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||||
|
)
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
|
|
||||||
// Local library item is linked to a server library item
|
// Local library item is linked to a server library item
|
||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
// Send sync to server also if connected to this server and local item belongs to this
|
||||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
// server
|
||||||
|
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||||
|
if (hasNetworkConnection &&
|
||||||
|
shouldSyncServer &&
|
||||||
|
!it.libraryItemId.isNullOrEmpty() &&
|
||||||
|
isConnectedToSameServer
|
||||||
|
) {
|
||||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
} else {
|
} else {
|
||||||
failedSyncs++
|
failedSyncs++
|
||||||
if (failedSyncs == 2) {
|
if (failedSyncs == 2) {
|
||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)")
|
||||||
cb(SyncResult(false, null, null))
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
@@ -278,18 +312,20 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||||
}
|
}
|
||||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)")
|
||||||
cb(SyncResult(false, null, null))
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
private fun saveLocalProgress(playbackSession: PlaybackSession) {
|
||||||
if (currentLocalMediaProgress == null) {
|
if (currentLocalMediaProgress == null) {
|
||||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
val mediaProgress =
|
||||||
|
DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||||
if (mediaProgress == null) {
|
if (mediaProgress == null) {
|
||||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||||
} else {
|
} else {
|
||||||
@@ -306,12 +342,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
} else {
|
} else {
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.audiobookshelf.app.media
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.AnyRes
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get uri to drawable or any other resource type if u wish
|
||||||
|
* @param drawableId - drawable res id
|
||||||
|
* @return - uri
|
||||||
|
*/
|
||||||
|
fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri {
|
||||||
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get uri to drawable or any other resource type if u wish
|
||||||
|
* @param drawableId - drawable res id
|
||||||
|
* @return - uri
|
||||||
|
*/
|
||||||
|
fun getUriToAbsIconDrawable(context: Context, absIconName: String): Uri {
|
||||||
|
val drawableId = when(absIconName) {
|
||||||
|
"audiobookshelf" -> R.drawable.abs_audiobookshelf
|
||||||
|
"authors" -> R.drawable.abs_authors
|
||||||
|
"book-1" -> R.drawable.abs_book_1
|
||||||
|
"books-1" -> R.drawable.abs_books_1
|
||||||
|
"books-2" -> R.drawable.abs_books_2
|
||||||
|
"columns" -> R.drawable.abs_columns
|
||||||
|
"database" -> R.drawable.abs_database
|
||||||
|
"file-picture" -> R.drawable.abs_file_picture
|
||||||
|
"headphones" -> R.drawable.abs_headphones
|
||||||
|
"heart" -> R.drawable.abs_heart
|
||||||
|
"microphone_1" -> R.drawable.abs_microphone_1
|
||||||
|
"microphone_2" -> R.drawable.abs_microphone_2
|
||||||
|
"microphone_3" -> R.drawable.abs_microphone_3
|
||||||
|
"music" -> R.drawable.abs_music
|
||||||
|
"podcast" -> R.drawable.abs_podcast
|
||||||
|
"radio" -> R.drawable.abs_radio
|
||||||
|
"rocket" -> R.drawable.abs_rocket
|
||||||
|
"rss" -> R.drawable.abs_rss
|
||||||
|
"star" -> R.drawable.abs_star
|
||||||
|
else -> R.drawable.icon_library_folder
|
||||||
|
}
|
||||||
|
return Uri.parse(
|
||||||
|
ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||||
|
}
|
||||||
@@ -6,34 +6,37 @@ import com.audiobookshelf.app.data.MediaType
|
|||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
|
||||||
data class DownloadItem(
|
data class DownloadItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val libraryItemId:String,
|
val libraryItemId: String,
|
||||||
val episodeId:String?,
|
val episodeId: String?,
|
||||||
val userMediaProgress: MediaProgress?,
|
val userMediaProgress: MediaProgress?,
|
||||||
val serverConnectionConfigId:String,
|
val serverConnectionConfigId: String,
|
||||||
val serverAddress:String,
|
val serverAddress: String,
|
||||||
val serverUserId:String,
|
val serverUserId: String,
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
val itemFolderPath:String,
|
val itemFolderPath: String,
|
||||||
val localFolder: LocalFolder,
|
val localFolder: LocalFolder,
|
||||||
val itemTitle: String,
|
val itemTitle: String,
|
||||||
val itemSubfolder: String,
|
val itemSubfolder: String,
|
||||||
val media: MediaType,
|
val media: MediaType,
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||||
|
@JsonIgnore var terminalFailureAt: Long? = null
|
||||||
) {
|
) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
val isInternalStorage
|
||||||
|
get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
val isDownloadFinished
|
||||||
|
get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed }
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||||
val itemParts = mutableListOf<DownloadItemPart>()
|
val itemParts = mutableListOf<DownloadItemPart>()
|
||||||
if (limit == 0) return itemParts
|
if (limit == 0) return itemParts
|
||||||
|
|
||||||
for (it in downloadItemParts) {
|
for (it in downloadItemParts) {
|
||||||
if (!it.completed && it.downloadId == null) {
|
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||||
itemParts.add(it)
|
itemParts.add(it)
|
||||||
if (itemParts.size >= limit) break
|
if (itemParts.size >= limit) break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.audiobookshelf.app.models
|
package com.audiobookshelf.app.models
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.AudioTrack
|
import com.audiobookshelf.app.data.AudioTrack
|
||||||
@@ -16,6 +15,7 @@ data class DownloadItemPart(
|
|||||||
val downloadItemId: String,
|
val downloadItemId: String,
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val fileSize: Long,
|
val fileSize: Long,
|
||||||
|
@JsonIgnore val destinationPath: String,
|
||||||
val finalDestinationPath:String,
|
val finalDestinationPath:String,
|
||||||
val serverPath: String,
|
val serverPath: String,
|
||||||
val localFolderName: String,
|
val localFolderName: String,
|
||||||
@@ -31,25 +31,29 @@ data class DownloadItemPart(
|
|||||||
@JsonIgnore val uri: Uri,
|
@JsonIgnore val uri: Uri,
|
||||||
@JsonIgnore val destinationUri: Uri,
|
@JsonIgnore val destinationUri: Uri,
|
||||||
@JsonIgnore val finalDestinationUri: Uri,
|
@JsonIgnore val finalDestinationUri: Uri,
|
||||||
|
@JsonIgnore var completedDestinationUri: String?,
|
||||||
val finalDestinationSubfolder: String,
|
val finalDestinationSubfolder: String,
|
||||||
var downloadId: Long?,
|
var downloadId: Long?,
|
||||||
|
@JsonIgnore var lastUpdateTime: Long?,
|
||||||
var progress: Long,
|
var progress: Long,
|
||||||
var bytesDownloaded: Long
|
var bytesDownloaded: Long,
|
||||||
|
@JsonIgnore var retryCount: Int = 0,
|
||||||
|
@JsonIgnore var waitingForSpace: Boolean = false
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||||
val destinationUri = Uri.fromFile(destinationFile)
|
val destinationUri = Uri.fromFile(destinationFile)
|
||||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||||
|
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
|
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
|
||||||
|
|
||||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
|
||||||
val downloadUri = Uri.parse(downloadUrl)
|
|
||||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
|
||||||
return DownloadItemPart(
|
return DownloadItemPart(
|
||||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||||
downloadItemId,
|
downloadItemId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
fileSize = fileSize,
|
fileSize = fileSize,
|
||||||
|
destinationPath = destinationFile.absolutePath,
|
||||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||||
serverPath = serverPath,
|
serverPath = serverPath,
|
||||||
localFolderName = localFolder.name,
|
localFolderName = localFolder.name,
|
||||||
@@ -65,8 +69,10 @@ data class DownloadItemPart(
|
|||||||
uri = downloadUri,
|
uri = downloadUri,
|
||||||
destinationUri = destinationUri,
|
destinationUri = destinationUri,
|
||||||
finalDestinationUri = finalDestinationUri,
|
finalDestinationUri = finalDestinationUri,
|
||||||
|
completedDestinationUri = null,
|
||||||
finalDestinationSubfolder = subfolder,
|
finalDestinationSubfolder = subfolder,
|
||||||
downloadId = null,
|
downloadId = null,
|
||||||
|
lastUpdateTime = null,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
bytesDownloaded = 0
|
bytesDownloaded = 0
|
||||||
)
|
)
|
||||||
@@ -76,16 +82,4 @@ data class DownloadItemPart(
|
|||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
|
||||||
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDownloadRequest(): DownloadManager.Request {
|
|
||||||
val dlRequest = DownloadManager.Request(uri)
|
|
||||||
dlRequest.setTitle(filename)
|
|
||||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
|
||||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
|
||||||
dlRequest.setDestinationUri(destinationUri)
|
|
||||||
return dlRequest
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ import android.graphics.ImageDecoder
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.BuildConfig
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
private val tag = "MediaDescriptionAdapter"
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
private var currentIconUri: Uri? = null
|
private var currentIconUri: Uri? = null
|
||||||
@@ -36,12 +33,19 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
callback: PlayerNotificationManager.BitmapCallback
|
callback: PlayerNotificationManager.BitmapCallback
|
||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
val albumArtUri = controller.metadata.description.iconUri
|
val albumArtUri = controller.metadata.description.iconUri
|
||||||
|
val albumBitmap = controller.metadata.description.iconBitmap
|
||||||
|
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
|
||||||
|
|
||||||
|
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
|
||||||
|
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||||
|
if (albumBitmap != null) {
|
||||||
|
return albumBitmap
|
||||||
|
}
|
||||||
|
|
||||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||||
// Cache the bitmap for the current audiobook so that successive calls to
|
// Cache the bitmap for the current audiobook so that successive calls to
|
||||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||||
currentIconUri = albumArtUri
|
currentIconUri = albumArtUri
|
||||||
Log.d(tag, "ART $currentIconUri")
|
|
||||||
|
|
||||||
if (currentIconUri.toString().startsWith("content://")) {
|
if (currentIconUri.toString().startsWith("content://")) {
|
||||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
@@ -55,37 +59,14 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
} else {
|
} else {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
currentBitmap = albumArtUri?.let {
|
currentBitmap = albumArtUri?.let {
|
||||||
resolveUriAsBitmap(it)
|
resolveUriAsBitmap(playerNotificationService, it)
|
||||||
}
|
}
|
||||||
currentBitmap?.let { callback.onBitmap(it) }
|
currentBitmap?.let { callback.onBitmap(it) }
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
currentBitmap
|
currentBitmap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
Glide.with(playerNotificationService)
|
|
||||||
.asBitmap()
|
|
||||||
.load(uri)
|
|
||||||
.placeholder(R.drawable.icon)
|
|
||||||
.error(R.drawable.icon)
|
|
||||||
.submit()
|
|
||||||
.get()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
|
|
||||||
Glide.with(playerNotificationService)
|
|
||||||
.asBitmap()
|
|
||||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
|
||||||
.submit()
|
|
||||||
.get()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,45 @@
|
|||||||
package com.audiobookshelf.app.player
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import androidx.annotation.AnyRes
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.media.getUriToDrawable
|
||||||
|
|
||||||
class BrowseTree(
|
class BrowseTree(
|
||||||
val context: Context,
|
val context: Context,
|
||||||
itemsInProgress: List<ItemInProgress>,
|
itemsInProgress: List<ItemInProgress>,
|
||||||
libraries: List<Library>
|
libraries: List<Library>,
|
||||||
|
recentsLoaded: Boolean
|
||||||
) {
|
) {
|
||||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||||
|
|
||||||
/**
|
|
||||||
* get uri to drawable or any other resource type if u wish
|
|
||||||
* @param drawableId - drawable res id
|
|
||||||
* @return - uri
|
|
||||||
*/
|
|
||||||
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
|
||||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
|
||||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||||
|
|
||||||
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val recentMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent")
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.md_clock_outline).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
if (itemsInProgress.isNotEmpty()) {
|
if (itemsInProgress.isNotEmpty()) {
|
||||||
@@ -53,13 +47,28 @@ class BrowseTree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraries.isNotEmpty()) {
|
if (libraries.isNotEmpty()) {
|
||||||
|
if (recentsLoaded) {
|
||||||
|
rootList += recentMetadata
|
||||||
|
}
|
||||||
rootList += librariesMetadata
|
rootList += librariesMetadata
|
||||||
|
|
||||||
libraries.forEach { library ->
|
libraries.forEach { library ->
|
||||||
val libraryMediaMetadata = library.getMediaMetadata()
|
// Skip libraries without audio content
|
||||||
|
if (library.stats?.numAudioFiles == 0) return@forEach
|
||||||
|
Log.d("BrowseTree", "Library $library | ${library.icon}")
|
||||||
|
// Generate library list items for Libraries menu
|
||||||
|
val libraryMediaMetadata = library.getMediaMetadata(context)
|
||||||
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||||
children += libraryMediaMetadata
|
children += libraryMediaMetadata
|
||||||
mediaIdToChildren[LIBRARIES_ROOT] = children
|
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||||
|
|
||||||
|
if (recentsLoaded) {
|
||||||
|
// Generate library list items for Recent menu
|
||||||
|
val recentlyMediaMetadata = library.getMediaMetadata(context,"recently")
|
||||||
|
val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf()
|
||||||
|
childrenRecently += recentlyMediaMetadata
|
||||||
|
mediaIdToChildren[RECENTLY_ROOT] = childrenRecently
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,3 +84,4 @@ const val AUTO_BROWSE_ROOT = "/"
|
|||||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
const val RECENTLY_ROOT = "__RECENTLY__"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@@ -53,6 +54,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
private var myCurrentTrackGroups: TrackGroupArray
|
private var myCurrentTrackGroups: TrackGroupArray
|
||||||
private var myCurrentTrackSelections: TrackSelectionArray
|
private var myCurrentTrackSelections: TrackSelectionArray
|
||||||
|
|
||||||
|
// Cache for pending volume to handle async setVolume
|
||||||
|
private var pendingVolume: Int? = null
|
||||||
|
private var pendingVolumeSetAt: Long = 0L
|
||||||
|
|
||||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
var currentMediaItems:List<MediaItem> = mutableListOf()
|
||||||
var remoteMediaClient:RemoteMediaClient? = null
|
var remoteMediaClient:RemoteMediaClient? = null
|
||||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
||||||
@@ -76,7 +81,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||||
COMMAND_GET_TRACKS)
|
COMMAND_GET_TRACKS,
|
||||||
|
COMMAND_GET_DEVICE_VOLUME,
|
||||||
|
COMMAND_SET_DEVICE_VOLUME,
|
||||||
|
COMMAND_ADJUST_DEVICE_VOLUME)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
var currentPlaybackState = Player.STATE_IDLE
|
var currentPlaybackState = Player.STATE_IDLE
|
||||||
@@ -861,27 +869,66 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceVolume(): Int {
|
override fun getDeviceVolume(): Int {
|
||||||
return 0
|
// Short-lived cache without even-value quantization
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) {
|
||||||
|
return pendingVolume!!
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
val vol = if (session != null && !session.isMute) {
|
||||||
|
(session.volume * 100).roundToInt().coerceIn(0, 100)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingVolume != null && vol == pendingVolume) {
|
||||||
|
pendingVolume = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return vol
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isDeviceMuted(): Boolean {
|
override fun isDeviceMuted(): Boolean {
|
||||||
return false
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
return session?.isMute ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDeviceVolume(volume: Int) {
|
override fun setDeviceVolume(volume: Int) {
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
try {
|
||||||
|
val clamped = volume.coerceIn(0, 100)
|
||||||
|
val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 }
|
||||||
|
|
||||||
|
// Cache for 100ms to return in getDeviceVolume during async period
|
||||||
|
pendingVolume = clamped
|
||||||
|
pendingVolumeSetAt = System.currentTimeMillis()
|
||||||
|
|
||||||
|
session?.setVolume(target)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to set cast device volume", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun increaseDeviceVolume() {
|
override fun increaseDeviceVolume() {
|
||||||
|
val current = getDeviceVolume()
|
||||||
|
val next = current + 1
|
||||||
|
setDeviceVolume(next.coerceAtMost(100))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun decreaseDeviceVolume() {
|
override fun decreaseDeviceVolume() {
|
||||||
|
val current = getDeviceVolume()
|
||||||
|
val next = current - 1
|
||||||
|
setDeviceVolume(next.coerceAtLeast(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setDeviceMuted(muted: Boolean) {
|
override fun setDeviceMuted(muted: Boolean) {
|
||||||
|
val session = castContext.sessionManager.currentCastSession
|
||||||
|
try {
|
||||||
|
session?.isMute = muted
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Failed to set cast device mute state", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val TAG = "CoverImageLoader"
|
||||||
|
|
||||||
|
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
|
||||||
|
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(uri)
|
||||||
|
.placeholder(R.drawable.icon)
|
||||||
|
.error(R.drawable.icon)
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
|
||||||
|
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
import com.audiobookshelf.app.data.PodcastEpisode
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.hardware.SensorEvent
|
|||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class ShakeDetector : SensorEventListener {
|
class ShakeDetector : SensorEventListener {
|
||||||
@@ -46,6 +47,7 @@ class ShakeDetector : SensorEventListener {
|
|||||||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
||||||
mShakeCount = 0
|
mShakeCount = 0
|
||||||
}
|
}
|
||||||
|
AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)")
|
||||||
mShakeTimestamp = now
|
mShakeTimestamp = now
|
||||||
mShakeCount++
|
mShakeCount++
|
||||||
mListener!!.onShake(mShakeCount)
|
mListener!!.onShake(mShakeCount)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
private var isCastAvailable:Boolean = false
|
private var isCastAvailable:Boolean = false
|
||||||
|
|
||||||
|
// Track foreground state to avoid flooding WebView with events while backgrounded
|
||||||
|
private var isInForeground: Boolean = true
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
@@ -60,6 +63,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
override fun onMetadata(metadata: PlaybackMetadata) {
|
||||||
|
// Skip frequent metadata updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +73,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||||
|
// Skip sleep timer updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
val ret = JSObject()
|
val ret = JSObject()
|
||||||
ret.put("value", sleepTimeRemaining)
|
ret.put("value", sleepTimeRemaining)
|
||||||
ret.put("isAuto", isAutoSleepTimer)
|
ret.put("isAuto", isAutoSleepTimer)
|
||||||
@@ -75,6 +82,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||||
|
// Skip progress updates when app is backgrounded to prevent event queue buildup
|
||||||
|
if (!isInForeground) return
|
||||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +127,27 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
notifyListeners(evtName, ret)
|
notifyListeners(evtName, ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun handleOnPause() {
|
||||||
|
super.handleOnPause()
|
||||||
|
isInForeground = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnResume() {
|
||||||
|
super.handleOnResume()
|
||||||
|
isInForeground = true
|
||||||
|
|
||||||
|
// Send current state to UI after resume to sync up (with small delay to let WebView fully resume)
|
||||||
|
if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||||
|
playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState()
|
||||||
|
playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let {
|
||||||
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun initCastManager() {
|
private fun initCastManager() {
|
||||||
val googleApi = GoogleApiAvailability.getInstance()
|
val googleApi = GoogleApiAvailability.getInstance()
|
||||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||||
@@ -180,7 +210,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
val startTimeOverride = call.getDouble("startTime")
|
val startTimeOverride = call.getDouble("startTime")
|
||||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
|
||||||
|
AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
@@ -198,7 +229,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!it.hasTracks(episode)) {
|
if (!it.hasTracks(mainActivity, episode)) {
|
||||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,10 +325,10 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun seek(call: PluginCall) {
|
fun seek(call: PluginCall) {
|
||||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional
|
||||||
Log.d(tag, "seek action to $time")
|
Log.d(tag, "seek action to $time")
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
playerNotificationService.seekPlayer((time * 1000L).toLong())
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaEventManager
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
@@ -22,20 +23,25 @@ class AbsDatabase : Plugin() {
|
|||||||
val tag = "AbsDatabase"
|
val tag = "AbsDatabase"
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
private lateinit var mainActivity: MainActivity
|
||||||
lateinit var apiHandler: ApiHandler
|
private lateinit var apiHandler: ApiHandler
|
||||||
|
private lateinit var secureStorage: SecureStorage
|
||||||
|
|
||||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||||
|
|
||||||
|
secureStorage = SecureStorage(mainActivity)
|
||||||
|
|
||||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
|
||||||
|
DeviceManager.dbManager.cleanLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -113,13 +119,15 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||||
|
|
||||||
val userId = serverConfigPayload.userId
|
val userId = serverConfigPayload.userId
|
||||||
val username = serverConfigPayload.username
|
val username = serverConfigPayload.username
|
||||||
val token = serverConfigPayload.token
|
val serverVersion = serverConfigPayload.version
|
||||||
|
val accessToken = serverConfigPayload.token
|
||||||
|
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
if (serverConnectionConfig == null) { // New Server Connection
|
if (serverConnectionConfig == null) { // New Server Connection
|
||||||
@@ -128,7 +136,16 @@ class AbsDatabase : Plugin() {
|
|||||||
// Create new server connection config
|
// Create new server connection config
|
||||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
|
||||||
|
// Store refresh token securely if provided
|
||||||
|
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
secureStorage.storeRefreshToken(sscId, refreshToken)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Log.d(tag, "Refresh token secured = $hasRefreshToken")
|
||||||
|
|
||||||
|
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
|
||||||
|
|
||||||
// Add and save
|
// Add and save
|
||||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||||
@@ -136,14 +153,21 @@ class AbsDatabase : Plugin() {
|
|||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
} else {
|
} else {
|
||||||
var shouldSave = false
|
var shouldSave = false
|
||||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
|
||||||
serverConnectionConfig?.userId = userId
|
serverConnectionConfig?.userId = userId
|
||||||
serverConnectionConfig?.username = username
|
serverConnectionConfig?.username = username
|
||||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||||
serverConnectionConfig?.token = token
|
serverConnectionConfig?.version = serverVersion
|
||||||
|
serverConnectionConfig?.token = accessToken
|
||||||
shouldSave = true
|
shouldSave = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update refresh token if provided
|
||||||
|
if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
|
||||||
|
Log.d(tag, "Refresh token secured = $stored")
|
||||||
|
}
|
||||||
|
|
||||||
// Set last connection config
|
// Set last connection config
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||||
@@ -162,6 +186,10 @@ class AbsDatabase : Plugin() {
|
|||||||
fun removeServerConnectionConfig(call:PluginCall) {
|
fun removeServerConnectionConfig(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
// Remove refresh token if it exists
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
|
||||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
@@ -174,6 +202,42 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getRefreshToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken != null) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("refreshToken", refreshToken)
|
||||||
|
call.resolve(result)
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun clearRefreshToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("success", refreshToken)
|
||||||
|
call.resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getAccessToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||||
|
val token = serverConnectionConfig?.token ?: ""
|
||||||
|
val ret = JSObject()
|
||||||
|
ret.put("token", token)
|
||||||
|
call.resolve(ret)
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun logout(call:PluginCall) {
|
fun logout(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
@@ -220,12 +284,11 @@ class AbsDatabase : Plugin() {
|
|||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||||
if (DeviceManager.serverConnectionConfig == null) {
|
if (DeviceManager.serverConnectionConfig == null) {
|
||||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
apiHandler.syncLocalMediaProgressForUser {
|
apiHandler.syncLocalMediaProgressForUser {
|
||||||
Log.d(tag, "Finished syncing local media progress for user")
|
|
||||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||||
|
|
||||||
if (savedSessions.isNotEmpty()) {
|
if (savedSessions.isNotEmpty()) {
|
||||||
@@ -233,6 +296,7 @@ class AbsDatabase : Plugin() {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.")
|
||||||
// Remove all local sessions
|
// Remove all local sessions
|
||||||
savedSessions.forEach {
|
savedSessions.forEach {
|
||||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||||
@@ -241,6 +305,7 @@ class AbsDatabase : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.")
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,7 +558,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
Log.d(tag, "updateDeviceSettings called")
|
||||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
@@ -511,7 +576,7 @@ class AbsDatabase : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
Log.d(tag, "getMediaItemHistory called")
|
||||||
val mediaId = call.getString("mediaId") ?: ""
|
val mediaId = call.getString("mediaId") ?: ""
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.audiobookshelf.app.models.DownloadItemPart
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||||
|
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -27,9 +25,7 @@ class AbsDownloader : Plugin() {
|
|||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
lateinit var mainActivity: MainActivity
|
||||||
lateinit var downloadManager: DownloadManager
|
|
||||||
lateinit var apiHandler: ApiHandler
|
lateinit var apiHandler: ApiHandler
|
||||||
lateinit var folderScanner: FolderScanner
|
|
||||||
lateinit var downloadItemManager: DownloadItemManager
|
lateinit var downloadItemManager: DownloadItemManager
|
||||||
|
|
||||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||||
@@ -42,14 +38,44 @@ class AbsDownloader : Plugin() {
|
|||||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||||
notifyListeners("onItemDownloadComplete", jsobj)
|
notifyListeners("onItemDownloadComplete", jsobj)
|
||||||
}
|
}
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
folderScanner = FolderScanner(mainActivity)
|
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||||
|
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleOnDestroy() {
|
||||||
|
DownloadServiceHost.detachBridge()
|
||||||
|
super.handleOnDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun setDownloadNotificationStrings(call: PluginCall) {
|
||||||
|
DownloadServiceHost.setNotificationStrings(
|
||||||
|
mainActivity,
|
||||||
|
call.getString("preparing") ?: "Preparing downloads",
|
||||||
|
call.getString("downloadingFile") ?: "Downloading {0}",
|
||||||
|
call.getString("waitingForStorage") ?: "Waiting for available storage",
|
||||||
|
call.getString("downloads") ?: "Downloads",
|
||||||
|
call.getString("cancel") ?: "Cancel")
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replays restored queue items when the frontend subscribes to download events. */
|
||||||
|
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||||
|
override fun addListener(call: PluginCall) {
|
||||||
|
super.addListener(call)
|
||||||
|
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||||
|
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||||
|
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -79,7 +105,7 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +158,13 @@ class AbsDownloader : Plugin() {
|
|||||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val isInternal = localFolder.id.startsWith("internal-")
|
val isInternal = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
|
||||||
|
val tempFolderPath =
|
||||||
|
if (isInternal) {
|
||||||
|
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||||
|
} else {
|
||||||
|
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||||
|
|
||||||
@@ -143,7 +175,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
val book = libraryItem.media as Book
|
val book = libraryItem.media as Book
|
||||||
@@ -152,17 +184,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
@@ -181,17 +203,7 @@ class AbsDownloader : Plugin() {
|
|||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
@@ -205,24 +217,14 @@ class AbsDownloader : Plugin() {
|
|||||||
|
|
||||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Podcast episode download
|
// Podcast episode download
|
||||||
@@ -233,7 +235,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
@@ -241,13 +243,8 @@ class AbsDownloader : Plugin() {
|
|||||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
if (finalDestinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
|
||||||
finalDestinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
@@ -258,18 +255,14 @@ class AbsDownloader : Plugin() {
|
|||||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
destinationFilename = "cover.jpg"
|
destinationFilename = "cover.jpg"
|
||||||
|
|
||||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
|
|
||||||
if (finalDestinationFile.exists()) {
|
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
} else {
|
|
||||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadItemManager.addDownloadItem(downloadItem)
|
DownloadServiceHost.enqueue(mainActivity, downloadItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -23,40 +24,62 @@ import java.io.File
|
|||||||
class AbsFileSystem : Plugin() {
|
class AbsFileSystem : Plugin() {
|
||||||
private val TAG = "AbsFileSystem"
|
private val TAG = "AbsFileSystem"
|
||||||
private val tag = "AbsFileSystem"
|
private val tag = "AbsFileSystem"
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper =
|
||||||
|
jacksonObjectMapper()
|
||||||
|
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
lateinit var mainActivity: MainActivity
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
|
|
||||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
mainActivity.storage.storageAccessCallback =
|
||||||
override fun onRootPathNotSelected(
|
object : StorageAccessCallback {
|
||||||
requestCode: Int,
|
override fun onRootPathNotSelected(
|
||||||
rootPath: String,
|
requestCode: Int,
|
||||||
uri: Uri,
|
rootPath: String,
|
||||||
selectedStorageType: StorageType,
|
uri: Uri,
|
||||||
expectedStorageType: StorageType
|
selectedStorageType: StorageType,
|
||||||
) {
|
expectedStorageType: StorageType
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
) {
|
||||||
}
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCanceledByUser(requestCode: Int) {
|
override fun onCanceledByUser(requestCode: Int) {
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
override fun onExpectedStorageNotSelected(
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
requestCode: Int,
|
||||||
}
|
selectedFolder: DocumentFile,
|
||||||
|
selectedStorageType: StorageType,
|
||||||
|
expectedBasePath: String,
|
||||||
|
expectedStorageType: StorageType
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun setFolderPickerStrings(call: PluginCall) {
|
||||||
|
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired"))
|
||||||
|
.putString(KEY_ALLOW, call.getString("allow"))
|
||||||
|
.putString(KEY_CANCEL, call.getString("cancel"))
|
||||||
|
.putString(KEY_ACCESS_DENIED, call.getString("accessDenied"))
|
||||||
|
.putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied"))
|
||||||
|
.apply()
|
||||||
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -65,60 +88,74 @@ class AbsFileSystem : Plugin() {
|
|||||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||||
|
|
||||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
mainActivity.storage.folderPickerCallback =
|
||||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
object : FolderPickerCallback {
|
||||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||||
val absolutePath = folder.getAbsolutePath(activity)
|
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||||
val storageType = folder.getStorageType(activity)
|
val absolutePath = folder.getAbsolutePath(activity)
|
||||||
val simplePath = folder.getSimplePath(activity)
|
val storageType = folder.getStorageType(activity)
|
||||||
val basePath = folder.getBasePath(activity)
|
val basePath = folder.getBasePath(activity)
|
||||||
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
val folderId =
|
||||||
|
android.util.Base64.encodeToString(
|
||||||
|
folder.id.toByteArray(),
|
||||||
|
android.util.Base64.DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
val localFolder =
|
||||||
|
LocalFolder(
|
||||||
|
folderId,
|
||||||
|
folder.name ?: "",
|
||||||
|
folder.uri.toString(),
|
||||||
|
basePath,
|
||||||
|
absolutePath,
|
||||||
|
storageType.toString(),
|
||||||
|
mediaType
|
||||||
|
)
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStorageAccessDenied(
|
override fun onStorageAccessDenied(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
folder: DocumentFile?,
|
folder: DocumentFile?,
|
||||||
storageType: StorageType,
|
storageType: StorageType,
|
||||||
storageId: String
|
storageId: String
|
||||||
) {
|
) {
|
||||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||||
|
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||||
|
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||||
builder.setMessage(
|
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
|
||||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
|
||||||
"\nWould you like to grant access to this folder?")
|
run {
|
||||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||||
run {
|
call.resolve(jsobj)
|
||||||
jsobj.put("error", "User Canceled, Access Denied")
|
}
|
||||||
call.resolve(jsobj)
|
}
|
||||||
|
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
|
||||||
|
mainActivity.storageHelper.requestStorageAccess(
|
||||||
|
REQUEST_CODE_SDCARD_ACCESS,
|
||||||
|
initialPath = FileFullPath(mainActivity, storageId, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
builder.show()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||||
|
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||||
|
call.resolve(jsobj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||||
|
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||||
|
val jsobj = JSObject()
|
||||||
|
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||||
|
call.resolve(jsobj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
|
||||||
builder.show()
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
|
||||||
jsobj.put("error", "Access Denied")
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
|
||||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
|
||||||
val jsobj = JSObject()
|
|
||||||
jsobj.put("error", "Permission Denied")
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||||
}
|
}
|
||||||
@@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() {
|
|||||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||||
|
|
||||||
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true)
|
||||||
|
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("value", hasAccess)
|
jsobj.put("value", hasAccess)
|
||||||
@@ -196,26 +233,29 @@ class AbsFileSystem : Plugin() {
|
|||||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||||
val file = File(absolutePath)
|
val file = File(absolutePath)
|
||||||
success = if (file.exists()) {
|
success =
|
||||||
file.deleteRecursively()
|
if (file.exists()) {
|
||||||
} else {
|
file.deleteRecursively()
|
||||||
true
|
} else {
|
||||||
}
|
true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
var subfolderPathToDelete = ""
|
var subfolderPathToDelete = ""
|
||||||
localLibraryItem?.folderId?.let { folderId ->
|
localLibraryItem?.folderId?.let { folderId ->
|
||||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||||
folder?.absolutePath?.let { folderPath ->
|
folder?.absolutePath?.let { folderPath ->
|
||||||
val splitAbsolutePath = absolutePath.split("/")
|
val splitAbsolutePath = absolutePath.split("/")
|
||||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
val fullSubDir =
|
||||||
|
splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||||
if (fullSubDir != folderPath) {
|
if (fullSubDir != folderPath) {
|
||||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
val subdirHasAnItem =
|
||||||
if (_localLibraryItem.id == localLibraryItemId) {
|
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||||
false
|
if (_localLibraryItem.id == localLibraryItemId) {
|
||||||
} else {
|
false
|
||||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
} else {
|
||||||
}
|
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve(JSObject("{\"success\":false}"))
|
call.resolve(JSObject("{\"success\":false}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun folderPickerString(key: String, defaultValue: String): String =
|
||||||
|
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.getString(key, defaultValue) ?: defaultValue
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FOLDER_PICKER_PREFERENCES = "folder_picker"
|
||||||
|
const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required"
|
||||||
|
const val KEY_ALLOW = "allow"
|
||||||
|
const val KEY_CANCEL = "cancel"
|
||||||
|
const val KEY_ACCESS_DENIED = "access_denied"
|
||||||
|
const val KEY_PERMISSION_DENIED = "permission_denied"
|
||||||
|
const val DEFAULT_WRITE_ACCESS_REQUIRED =
|
||||||
|
"You do not have write access to this folder. Would you like to grant access?"
|
||||||
|
const val DEFAULT_ALLOW = "Allow"
|
||||||
|
const val DEFAULT_CANCEL = "Cancel"
|
||||||
|
const val DEFAULT_ACCESS_DENIED = "Access denied"
|
||||||
|
const val DEFAULT_PERMISSION_DENIED = "Permission denied"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AbsLog(
|
||||||
|
var id:String,
|
||||||
|
var tag:String,
|
||||||
|
var level:String,
|
||||||
|
var message:String,
|
||||||
|
var timestamp:Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AbsLogList(val value:List<AbsLog>)
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "AbsLogger")
|
||||||
|
class AbsLogger : Plugin() {
|
||||||
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
|
override fun load() {
|
||||||
|
onLogEmitter = { log:AbsLog ->
|
||||||
|
notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log)))
|
||||||
|
}
|
||||||
|
info("AbsLogger", "load: AbsLogger plugin initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var onLogEmitter:((log:AbsLog) -> Unit)? = null
|
||||||
|
|
||||||
|
fun log(level:String, tag:String, message:String) {
|
||||||
|
val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis())
|
||||||
|
DeviceManager.dbManager.saveLog(absLog)
|
||||||
|
onLogEmitter?.let { it(absLog) }
|
||||||
|
}
|
||||||
|
fun info(tag:String, message:String) {
|
||||||
|
Log.i("AbsLogger", message)
|
||||||
|
log("info", tag, message)
|
||||||
|
}
|
||||||
|
fun error(tag:String, message:String) {
|
||||||
|
Log.e("AbsLogger", message)
|
||||||
|
log("error", tag, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun info(call: PluginCall) {
|
||||||
|
val msg = call.getString("message") ?: return call.reject("No message")
|
||||||
|
val tag = call.getString("tag") ?: ""
|
||||||
|
info(tag, msg)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun error(call: PluginCall) {
|
||||||
|
val msg = call.getString("message") ?: return call.reject("No message")
|
||||||
|
val tag = call.getString("tag") ?: ""
|
||||||
|
error(tag, msg)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getAllLogs(call: PluginCall) {
|
||||||
|
val absLogs = DeviceManager.dbManager.getAllLogs()
|
||||||
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs))))
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun clearLogs(call: PluginCall) {
|
||||||
|
DeviceManager.dbManager.removeAllLogs()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -12,8 +13,11 @@ import com.audiobookshelf.app.media.MediaProgressSyncData
|
|||||||
import com.audiobookshelf.app.media.SyncResult
|
import com.audiobookshelf.app.media.SyncResult
|
||||||
import com.audiobookshelf.app.models.User
|
import com.audiobookshelf.app.models.User
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.getcapacitor.JSArray
|
import com.getcapacitor.JSArray
|
||||||
@@ -31,11 +35,20 @@ import java.util.concurrent.TimeUnit
|
|||||||
class ApiHandler(var ctx:Context) {
|
class ApiHandler(var ctx:Context) {
|
||||||
val tag = "ApiHandler"
|
val tag = "ApiHandler"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// For sending data back to the Webview frontend
|
||||||
|
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
|
||||||
|
|
||||||
|
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
|
||||||
|
return ::absDatabaseNotifyListeners.isInitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var defaultClient = OkHttpClient()
|
private var defaultClient = OkHttpClient()
|
||||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
private var secureStorage = SecureStorage(ctx)
|
||||||
|
|
||||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||||
@@ -108,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
|
if (it.code == 401) {
|
||||||
|
// Handle 401 Unauthorized by attempting token refresh
|
||||||
|
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
|
||||||
|
handleTokenRefresh(request, httpClient, cb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!it.isSuccessful) {
|
if (!it.isSuccessful) {
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Unexpected code $response")
|
jsobj.put("error", "Unexpected code $response")
|
||||||
@@ -140,6 +160,269 @@ class ApiHandler(var ctx:Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* This function will:
|
||||||
|
* 1. Get the refresh token from secure storage for the current server connection
|
||||||
|
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||||
|
* 3. Update the stored tokens with the new access token
|
||||||
|
* 4. Retry the original request with the new access token
|
||||||
|
* 5. If refresh fails, handle logout
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request that failed with 401
|
||||||
|
* @param httpClient The HTTP client to use for the request
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
|
||||||
|
// Get current server connection config ID
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (serverConnectionConfigId.isEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No server connection available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken.isNullOrEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No refresh token available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||||
|
|
||||||
|
// Create refresh token request
|
||||||
|
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||||
|
val refreshRequest = Request.Builder()
|
||||||
|
.url(refreshEndpoint)
|
||||||
|
.addHeader("x-refresh-token", refreshToken)
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(EMPTY_REQUEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Make the refresh request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
try {
|
||||||
|
val responseJson = JSONObject(bodyString)
|
||||||
|
val userObj = responseJson.optJSONObject("user")
|
||||||
|
|
||||||
|
if (userObj == null) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newAccessToken = userObj.optString("accessToken")
|
||||||
|
val newRefreshToken = userObj.optString("refreshToken")
|
||||||
|
|
||||||
|
if (newAccessToken.isEmpty()) {
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||||
|
|
||||||
|
// Update tokens in secure storage and device manager
|
||||||
|
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||||
|
|
||||||
|
// Retry the original request with the new access token
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||||
|
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||||
|
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stored tokens with new access and refresh tokens
|
||||||
|
*
|
||||||
|
* @param newAccessToken The new access token
|
||||||
|
* @param newRefreshToken The new refresh token (or existing one if not provided)
|
||||||
|
*/
|
||||||
|
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||||
|
try {
|
||||||
|
// Update the refresh token in secure storage if it's new
|
||||||
|
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
|
||||||
|
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
|
||||||
|
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the access token in the current server connection config
|
||||||
|
DeviceManager.serverConnectionConfig?.let { config ->
|
||||||
|
config.token = newAccessToken
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send access token to Webview frontend
|
||||||
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
|
val tokenJsObject = JSObject()
|
||||||
|
tokenJsObject.put("accessToken", newAccessToken)
|
||||||
|
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||||
|
} else {
|
||||||
|
// Can happen if Webview is never run
|
||||||
|
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
|
||||||
|
}
|
||||||
|
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "updateTokens: Failed to update tokens", e)
|
||||||
|
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries the original request with the new access token
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request to retry
|
||||||
|
* @param newAccessToken The new access token to use
|
||||||
|
* @param httpClient The HTTP client to use
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
// Create a new request with the updated authorization header
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.removeHeader("Authorization")
|
||||||
|
.addHeader("Authorization", "Bearer $newAccessToken")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
|
||||||
|
|
||||||
|
// Make the retry request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(newRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request after token refresh")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Retry request failed with status ${it.code}")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
if (bodyString == "OK") {
|
||||||
|
callback(JSObject())
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var jsonObj = JSObject()
|
||||||
|
if (bodyString.startsWith("[")) {
|
||||||
|
val array = JSArray(bodyString)
|
||||||
|
jsonObj.put("value", array)
|
||||||
|
} else {
|
||||||
|
jsonObj = JSObject(bodyString)
|
||||||
|
}
|
||||||
|
callback(jsonObj)
|
||||||
|
} catch(je:JSONException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Invalid response body")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
|
||||||
|
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* This will clear the current session and notify the callback
|
||||||
|
*
|
||||||
|
* @param callback The callback to return the error
|
||||||
|
*/
|
||||||
|
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||||
|
|
||||||
|
// Clear the current server connection
|
||||||
|
DeviceManager.serverConnectionConfig = null
|
||||||
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
|
||||||
|
// Remove refresh token from secure storage
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (serverConnectionConfigId.isNotEmpty()) {
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed - please login again")
|
||||||
|
callback(errorObj)
|
||||||
|
|
||||||
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
|
val tokenJsObject = JSObject()
|
||||||
|
tokenJsObject.put("error", "Token refresh failed")
|
||||||
|
if (serverConnectionConfigId.isNotEmpty()) {
|
||||||
|
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||||
|
} else {
|
||||||
|
// Can happen if Webview is never run
|
||||||
|
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||||
getRequest("/api/me", null, null) {
|
getRequest("/api/me", null, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
@@ -154,7 +437,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
fun getLibraries(cb: (List<Library>) -> Unit) {
|
||||||
val mapper = jacksonMapper
|
val mapper = jacksonMapper
|
||||||
getRequest("/api/libraries", null,null) {
|
getRequest("/api/libraries?include=stats", null,null) {
|
||||||
val libraries = mutableListOf<Library>()
|
val libraries = mutableListOf<Library>()
|
||||||
|
|
||||||
var array = JSONArray()
|
var array = JSONArray()
|
||||||
@@ -171,6 +454,23 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLibraryPersonalized(libraryItemId:String, cb: (List<LibraryShelfType>?) -> Unit) {
|
||||||
|
getRequest("/api/libraries/$libraryItemId/personalized", null, null) {
|
||||||
|
if (it.has("error")) {
|
||||||
|
Log.e(tag, it.getString("error") ?: "getLibraryStats Failed")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val items = mutableListOf<LibraryShelfType>()
|
||||||
|
val array = it.getJSONArray("value")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibraryShelfType>(array.get(i).toString())
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
||||||
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
@@ -211,6 +511,103 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLibrarySeries(libraryId:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||||
|
Log.d(tag, "Getting series")
|
||||||
|
getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) {
|
||||||
|
val items = mutableListOf<LibrarySeriesItem>()
|
||||||
|
if (it.has("results")) {
|
||||||
|
val array = it.getJSONArray("results")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibrarySeriesItem>(array.get(i).toString())
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
Log.d(tag, "Getting items for series")
|
||||||
|
val seriesIdBase64 = Base64.encodeToString(seriesId.toByteArray(), Base64.DEFAULT)
|
||||||
|
getRequest("/api/libraries/$libraryId/items?minified=1&sort=media.metadata.title&filter=series.${seriesIdBase64}&limit=1000", null, null) {
|
||||||
|
val items = mutableListOf<LibraryItem>()
|
||||||
|
if (it.has("results")) {
|
||||||
|
val array = it.getJSONArray("results")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibraryAuthors(libraryId:String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||||
|
Log.d(tag, "Getting series")
|
||||||
|
getRequest("/api/libraries/$libraryId/authors", null, null) {
|
||||||
|
val items = mutableListOf<LibraryAuthorItem>()
|
||||||
|
if (it.has("authors")) {
|
||||||
|
val array = it.getJSONArray("authors")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibraryAuthorItem>(array.get(i).toString())
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
Log.e(tag, "No results")
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||||
|
Log.d(tag, "Getting author items")
|
||||||
|
val authorIdBase64 = Base64.encodeToString(authorId.toByteArray(), Base64.DEFAULT)
|
||||||
|
getRequest("/api/libraries/$libraryId/items?limit=1000&minified=1&filter=authors.${authorIdBase64}&sort=media.metadata.title&collapseseries=1", null, null) {
|
||||||
|
val items = mutableListOf<LibraryItem>()
|
||||||
|
if (it.has("results")) {
|
||||||
|
val array = it.getJSONArray("results")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
|
||||||
|
if (item.collapsedSeries != null) {
|
||||||
|
item.collapsedSeries?.libraryId = libraryId
|
||||||
|
}
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
Log.e(tag, "No results")
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibraryCollections(libraryId:String, cb: (List<LibraryCollection>) -> Unit) {
|
||||||
|
Log.d(tag, "Getting collections")
|
||||||
|
getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) {
|
||||||
|
val items = mutableListOf<LibraryCollection>()
|
||||||
|
if (it.has("results")) {
|
||||||
|
val array = it.getJSONArray("results")
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val item = jacksonMapper.readValue<LibraryCollection>(array.get(i).toString())
|
||||||
|
items.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cb(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSearchResults(libraryId:String, queryString:String, cb: (LibraryItemSearchResultType?) -> Unit) {
|
||||||
|
Log.d(tag, "Doing search for library $libraryId")
|
||||||
|
getRequest("/api/libraries/$libraryId/search?q=$queryString", null, null) {
|
||||||
|
if (it.has("error")) {
|
||||||
|
Log.e(tag, it.getString("error") ?: "getSearchResults Failed")
|
||||||
|
cb(null)
|
||||||
|
} else {
|
||||||
|
val librarySearchResults = jacksonMapper.readValue<LibraryItemSearchResultType>(it.toString())
|
||||||
|
cb(librarySearchResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||||
getRequest("/api/me/items-in-progress", null, null) {
|
getRequest("/api/me/items-in-progress", null, null) {
|
||||||
val items = mutableListOf<ItemInProgress>()
|
val items = mutableListOf<ItemInProgress>()
|
||||||
@@ -218,8 +615,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val array = it.getJSONArray("libraryItems")
|
val array = it.getJSONArray("libraryItems")
|
||||||
for (i in 0 until array.length()) {
|
for (i in 0 until array.length()) {
|
||||||
val jsobj = array.get(i) as JSONObject
|
val jsobj = array.get(i) as JSONObject
|
||||||
|
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper)
|
||||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
|
||||||
items.add(itemInProgress)
|
items.add(itemInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,10 +652,29 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
val json = jacksonMapper.createObjectNode()
|
||||||
|
json.put("id", playbackSession.id)
|
||||||
|
json.put("userId", playbackSession.userId)
|
||||||
|
json.put("libraryItemId", playbackSession.libraryItemId)
|
||||||
|
json.put("episodeId", playbackSession.episodeId)
|
||||||
|
json.put("mediaType", playbackSession.mediaType)
|
||||||
|
json.put("displayTitle", playbackSession.displayTitle)
|
||||||
|
json.put("displayAuthor", playbackSession.displayAuthor)
|
||||||
|
json.put("duration", playbackSession.duration)
|
||||||
|
json.put("playMethod", playbackSession.playMethod)
|
||||||
|
json.put("startedAt", playbackSession.startedAt)
|
||||||
|
json.put("updatedAt", playbackSession.updatedAt)
|
||||||
|
json.put("timeListening", playbackSession.timeListening)
|
||||||
|
json.put("currentTime", playbackSession.currentTime)
|
||||||
|
json.put("mediaPlayer", playbackSession.mediaPlayer)
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
postRequest("/api/session/local", payload, null) {
|
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||||
|
val partialSession = createPartialPlaybackSession(playbackSession)
|
||||||
|
partialSession.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo))
|
||||||
|
postRequest("/api/session/local", JSObject(partialSession.toString()), null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
@@ -352,23 +767,30 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
val json = jacksonMapper.createObjectNode();
|
||||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession))
|
||||||
postRequest("/api/session/local-all", payload, null) {
|
json.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(deviceInfo))
|
||||||
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
|
postRequest("/api/session/local-all", JSObject(json.toString()), null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
Log.e(tag, "Failed to sync local sessions")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||||
response.results.forEach { localSessionSyncResult ->
|
response.results.forEach { localSessionSyncResult ->
|
||||||
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||||
|
|
||||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||||
if (localSessionSyncResult.progressSynced == true) {
|
if (localSessionSyncResult.progressSynced == true) {
|
||||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||||
MediaEventManager.saveEvent(session, syncResult)
|
MediaEventManager.saveEvent(session, syncResult)
|
||||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
|
||||||
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}")
|
||||||
} else if (!localSessionSyncResult.success) {
|
} else if (!localSessionSyncResult.success) {
|
||||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}")
|
||||||
|
} else {
|
||||||
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,37 +800,72 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||||
|
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}")
|
||||||
|
|
||||||
// Get all local media progress for this server
|
// Get all local media progress for this server
|
||||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||||
if (allLocalMediaProgress.isEmpty()) {
|
if (allLocalMediaProgress.isEmpty()) {
|
||||||
Log.d(tag, "No local media progress to sync")
|
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync")
|
||||||
return cb()
|
return cb()
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentUser { _user ->
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress")
|
||||||
_user?.let { user->
|
|
||||||
|
getCurrentUser { user ->
|
||||||
|
if (user == null) {
|
||||||
|
AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
} else {
|
||||||
|
var numLocalMediaProgressUptToDate = 0
|
||||||
|
var numLocalMediaProgressUpdated = 0
|
||||||
|
|
||||||
// Compare server user progress with local progress
|
// Compare server user progress with local progress
|
||||||
user.mediaProgress.forEach { mediaProgress ->
|
user.mediaProgress.forEach { mediaProgress ->
|
||||||
// Get matching local media progress
|
// Get matching local media progress
|
||||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
val updateLogs = mutableListOf<String>()
|
||||||
|
if (mediaProgress.progress != localMediaProgress.progress) {
|
||||||
|
updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.currentTime != localMediaProgress.currentTime) {
|
||||||
|
updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.isFinished != localMediaProgress.isFinished) {
|
||||||
|
updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||||
|
}
|
||||||
|
if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) {
|
||||||
|
updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||||
|
}
|
||||||
|
if (updateLogs.isNotEmpty()) {
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
|
||||||
|
// Only report sync if progress changed
|
||||||
|
if (updateLogs.isNotEmpty()) {
|
||||||
|
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||||
|
}
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
|
numLocalMediaProgressUpdated++
|
||||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||||
// Patch ebook progress to server
|
// Patch ebook progress to server
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}")
|
||||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||||
val updatePayload = JSObject()
|
val updatePayload = JSObject()
|
||||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||||
patchRequest(endpoint,updatePayload) {
|
patchRequest(endpoint,updatePayload) {
|
||||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
numLocalMediaProgressUptToDate++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated")
|
||||||
}
|
}
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.audiobookshelf.app.services
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
|
|
||||||
|
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
|
||||||
|
class DownloadService : Service() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||||
|
DownloadServiceHost.attachService(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||||
|
else -> {
|
||||||
|
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||||
|
DownloadServiceHost.ensure(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
DownloadServiceHost.detachService(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
fun onPartUpdate(part: DownloadItemPart) {
|
||||||
|
val strings = DownloadServiceHost.notificationStrings(this)
|
||||||
|
val text =
|
||||||
|
if (part.waitingForSpace) strings.waitingForStorage
|
||||||
|
else strings.downloadingFile.replace("{0}", part.filename)
|
||||||
|
val progress = part.progress.coerceIn(0L, 100L).toInt()
|
||||||
|
val notification = notification(text, progress, part.fileSize > 0L)
|
||||||
|
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
if (!hasWork) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithType(text: String) {
|
||||||
|
val notification = notification(text)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
|
||||||
|
val cancelIntent = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.icon)
|
||||||
|
.setContentTitle(DownloadServiceHost.notificationStrings(this).downloads)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(100, progress, !determinate)
|
||||||
|
.addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
DownloadServiceHost.notificationStrings(this).downloads,
|
||||||
|
NotificationManager.IMPORTANCE_LOW))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "downloads"
|
||||||
|
private const val NOTIFICATION_ID = 11
|
||||||
|
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
|
||||||
|
fun intent(context: Context) = Intent(context, DownloadService::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.audiobookshelf.app.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.audiobookshelf.app.device.FolderScanner
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
|
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||||
|
object DownloadServiceHost {
|
||||||
|
data class NotificationStrings(
|
||||||
|
val preparing: String,
|
||||||
|
val downloadingFile: String,
|
||||||
|
val waitingForStorage: String,
|
||||||
|
val downloads: String,
|
||||||
|
val cancel: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private var manager: DownloadItemManager? = null
|
||||||
|
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||||
|
private var service: DownloadService? = null
|
||||||
|
@Volatile private var bridgeReady = false
|
||||||
|
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun ensure(context: Context): DownloadItemManager {
|
||||||
|
if (manager == null) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
DbManager.initialize(appContext)
|
||||||
|
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||||
|
manager!!.restoreQueue()
|
||||||
|
}
|
||||||
|
return manager!!
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attaches the frontend after restored queue items have been emitted. */
|
||||||
|
@Synchronized
|
||||||
|
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||||
|
bridgeReady = false
|
||||||
|
bridgeEmitter = emitter
|
||||||
|
val queue = ensure(context)
|
||||||
|
queue.setEventEmitter(ForwardingEmitter)
|
||||||
|
bridgeReady = true
|
||||||
|
val completions = synchronized(deferredCompletions) {
|
||||||
|
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||||
|
}
|
||||||
|
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||||
|
if (queue.hasWork()) startService(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun detachBridge() {
|
||||||
|
bridgeReady = false
|
||||||
|
bridgeEmitter = NoopEmitter
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun enqueue(context: Context, item: DownloadItem) {
|
||||||
|
ensure(context).addDownloadItem(item)
|
||||||
|
startService(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun cancelAll(context: Context) { ensure(context).cancelAll() }
|
||||||
|
|
||||||
|
fun setNotificationStrings(
|
||||||
|
context: Context,
|
||||||
|
preparing: String,
|
||||||
|
downloadingFile: String,
|
||||||
|
waitingForStorage: String,
|
||||||
|
downloads: String,
|
||||||
|
cancel: String
|
||||||
|
) {
|
||||||
|
context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_PREPARING, preparing)
|
||||||
|
.putString(KEY_DOWNLOADING_FILE, downloadingFile)
|
||||||
|
.putString(KEY_WAITING_FOR_STORAGE, waitingForStorage)
|
||||||
|
.putString(KEY_DOWNLOADS, downloads)
|
||||||
|
.putString(KEY_CANCEL, cancel)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notificationStrings(context: Context): NotificationStrings {
|
||||||
|
val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
return NotificationStrings(
|
||||||
|
preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING,
|
||||||
|
preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE)
|
||||||
|
?: DEFAULT_DOWNLOADING_FILE,
|
||||||
|
preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE)
|
||||||
|
?: DEFAULT_WAITING_FOR_STORAGE,
|
||||||
|
preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS,
|
||||||
|
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun attachService(downloadService: DownloadService) {
|
||||||
|
service = downloadService
|
||||||
|
service?.onQueueChanged(ensure(downloadService).hasWork())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun detachService(downloadService: DownloadService) {
|
||||||
|
if (service === downloadService) service = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startService(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||||
|
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
|
||||||
|
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
|
||||||
|
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
service?.onPartUpdate(downloadItemPart)
|
||||||
|
}
|
||||||
|
override fun onDownloadItemComplete(jsobj: JSObject) {
|
||||||
|
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
|
||||||
|
}
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) {
|
||||||
|
bridgeEmitter.onQueueChanged(hasWork)
|
||||||
|
service?.onQueueChanged(hasWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||||
|
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
|
||||||
|
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
|
||||||
|
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
|
||||||
|
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NOTIFICATION_PREFERENCES = "download_notifications"
|
||||||
|
private const val KEY_PREPARING = "preparing"
|
||||||
|
private const val KEY_DOWNLOADING_FILE = "downloading_file"
|
||||||
|
private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage"
|
||||||
|
private const val KEY_DOWNLOADS = "downloads"
|
||||||
|
private const val KEY_CANCEL = "cancel"
|
||||||
|
private const val DEFAULT_PREPARING = "Preparing downloads"
|
||||||
|
private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}"
|
||||||
|
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
|
||||||
|
private const val DEFAULT_DOWNLOADS = "Downloads"
|
||||||
|
private const val DEFAULT_CANCEL = "Cancel"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>
|
||||||
|
After Width: | Height: | Size: 560 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 532 B |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="27dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="27"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M26.719,15.761c-0.165,-0.138 -0.422,-0.341 -0.77,-0.581v-2.703c0,-6.891 -5.586,-12.477 -12.478,-12.477v0c-6.891,0 -12.477,5.586 -12.477,12.477v2.703c-0.348,0.241 -0.605,0.443 -0.77,0.581 -0.137,0.114 -0.223,0.285 -0.223,0.476 0,0 0,0 0,0.001v-0,3.238c0,0 0,0.001 0,0.001 0,0.191 0.086,0.361 0.222,0.475l0.001,0.001c0.385,0.321 1.269,0.993 2.645,1.683v0.315c0,0.849 0.548,1.537 1.223,1.537v0c0.675,0 1.223,-0.688 1.223,-1.537v-7.767c0,-0.849 -0.548,-1.537 -1.223,-1.537v0c-0.647,0 -1.177,0.632 -1.22,1.431l-0.003,0.002v-1.601c0,-5.856 4.747,-10.602 10.603,-10.602v0c5.856,0 10.603,4.747 10.603,10.602v1.601l-0.003,-0.002c-0.043,-0.799 -0.573,-1.431 -1.22,-1.431v0c-0.675,0 -1.223,0.688 -1.223,1.537v7.766c0,0.849 0.548,1.537 1.223,1.537v0c0.676,0 1.223,-0.688 1.223,-1.537v-0.315c1.376,-0.69 2.26,-1.362 2.645,-1.683 0.137,-0.114 0.223,-0.285 0.223,-0.476 0,-0 0,-0.001 0,-0.001v0,-3.237c0,-0 0,-0 0,-0 0,-0.191 -0.086,-0.361 -0.222,-0.475l-0.001,-0.001zM9.12,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM6.673,13.731h3.368v0.352h-3.368zM14.234,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM11.787,13.731h3.367v0.352h-3.367zM19.348,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM16.901,13.731h3.367v0.352h-3.367zM3.566,29.773h19.81c0.615,0 1.113,0.498 1.113,1.113v0c0,0.615 -0.498,1.113 -1.113,1.113h-19.81c-0.615,0 -1.113,-0.498 -1.113,-1.113v-0c0,-0.615 0.498,-1.113 1.113,-1.113z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M16,7.333c2.577,0 4.667,2.089 4.667,4.667v0c0,2.577 -2.089,4.667 -4.667,4.667v0c-2.577,0 -4.667,-2.089 -4.667,-4.667v0c0,-2.577 2.089,-4.667 4.667,-4.667v0zM6.667,10.667c0.747,0 1.44,0.2 2.04,0.56 -0.2,1.907 0.36,3.8 1.507,5.28 -0.667,1.28 -2,2.16 -3.547,2.16 -2.209,0 -4,-1.791 -4,-4v0c0,-2.209 1.791,-4 4,-4v0zM25.333,10.667c2.209,0 4,1.791 4,4v0c0,2.209 -1.791,4 -4,4v0c-1.547,0 -2.88,-0.88 -3.547,-2.16 1.147,-1.48 1.707,-3.373 1.507,-5.28 0.6,-0.36 1.293,-0.56 2.04,-0.56zM7.333,24.333c0,-2.76 3.88,-5 8.667,-5s8.667,2.24 8.667,5v2.333h-17.333v-2.333zM0,26.667v-2c0,-1.853 2.52,-3.413 5.933,-3.867 -0.787,0.907 -1.267,2.16 -1.267,3.533v2.333h-4.667zM32,26.667h-4.667v-2.333c0,-1.373 -0.48,-2.627 -1.267,-3.533 3.413,0.453 5.933,2.013 5.933,3.867v2z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M28,4v26h-21c-1.657,0 -3,-1.343 -3,-3s1.343,-3 3,-3h19v-24h-20c-2.2,0 -4,1.8 -4,4v24c0,2.2 1.8,4 4,4h24v-28h-2zM7.002,26v0c-0.001,0 -0.001,0 -0.002,0 -0.552,0 -1,0.448 -1,1s0.448,1 1,1c0.001,0 0.001,-0 0.002,-0v0h18.997v-2h-18.997z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,4v20h4v-20h-4zM16,6.667l5.333,17.333 4,-1.333 -5.333,-17.333 -4,1.333zM6.667,6.667v17.333h4v-17.333h-4zM4,25.333v2.667h24v-2.667h-24z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="36"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M7,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM6,10h-4v-2h4v2zM17,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM16,10h-4v-2h4v2zM23.909,5.546l-5.358,2.7c-0.491,0.247 -0.691,0.852 -0.443,1.343l8.999,17.861c0.247,0.491 0.852,0.691 1.343,0.443l5.358,-2.7c0.491,-0.247 0.691,-0.852 0.443,-1.343l-8.999,-17.861c-0.247,-0.491 -0.852,-0.691 -1.343,-0.443z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M19.2,6h-6.4v19.2h6.4v-19.2zM22.4,6v19.2h6.4v-19.2h-6.4zM9.6,6h-6.4v19.2h6.4v-19.2zM0,2.8h32v25.6h-32v-25.6z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="27dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="27"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M13.68,0c7.518,0 13.612,2.854 13.612,6.37 0,3.518 -6.096,6.37 -13.612,6.37s-13.612,-2.854 -13.612,-6.37c0,-3.516 6.096,-6.37 13.612,-6.37v0zM0.068,21.31v4.891c2.422,8.602 26.349,6.94 27.227,-0.44v-4.885c-1.195,8.102 -25.313,8.685 -27.227,0.435v0,0zM0,8.578v4.776c2.422,8.401 26.482,7.266 27.362,0.06v-4.773c-1.198,7.914 -25.448,7.995 -27.362,-0.063v0zM0,14.75v4.891c2.422,8.602 26.482,7.44 27.362,0.06v-4.885c-1.198,8.102 -25.448,8.185 -27.362,-0.065v0z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M26,28h-20v-4l6,-10 8.219,10 5.781,-4v8zM26,15c0,1.657 -1.343,3 -3,3s-3,-1.343 -3,-3 1.343,-3 3,-3c1.657,0 3,1.343 3,3zM28.681,7.159c-0.694,-0.947 -1.662,-2.053 -2.724,-3.116s-2.169,-2.03 -3.116,-2.724c-1.612,-1.182 -2.393,-1.319 -2.841,-1.319h-15.5c-1.378,0 -2.5,1.121 -2.5,2.5v27c0,1.378 1.122,2.5 2.5,2.5h23c1.378,0 2.5,-1.122 2.5,-2.5v-19.5c0,-0.448 -0.137,-1.23 -1.319,-2.841zM24.543,5.457c0.959,0.959 1.712,1.825 2.268,2.543h-4.811v-4.811c0.718,0.556 1.584,1.309 2.543,2.268zM28,29.5c0,0.271 -0.229,0.5 -0.5,0.5h-23c-0.271,0 -0.5,-0.229 -0.5,-0.5v-27c0,-0.271 0.229,-0.5 0.5,-0.5 0,0 15.499,-0 15.5,0v7c0,0.552 0.448,1 1,1h7v19.5z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M9,18h-2v14h2c0.55,0 1,-0.45 1,-1v-12c0,-0.55 -0.45,-1 -1,-1zM23,18c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2v-14h-2zM32,16c0,-8.837 -7.163,-16 -16,-16s-16,7.163 -16,16c0,1.919 0.338,3.759 0.958,5.464 -0.609,1.038 -0.958,2.246 -0.958,3.536 0,3.526 2.608,6.443 6,6.929v-13.857c-0.997,0.143 -1.927,0.495 -2.742,1.012 -0.168,-0.835 -0.258,-1.699 -0.258,-2.584 0,-7.18 5.82,-13 13,-13s13,5.82 13,13c0,0.885 -0.088,1.749 -0.257,2.584 -0.816,-0.517 -1.745,-0.87 -2.743,-1.013v13.858c3.392,-0.485 6,-3.402 6,-6.929 0,-1.29 -0.349,-2.498 -0.958,-3.536 0.62,-1.705 0.958,-3.545 0.958,-5.465z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M23.6,2c-3.363,0 -6.258,2.736 -7.599,5.594 -1.342,-2.858 -4.237,-5.594 -7.601,-5.594 -4.637,0 -8.4,3.764 -8.4,8.401 0,9.433 9.516,11.906 16.001,21.232 6.13,-9.268 15.999,-12.1 15.999,-21.232 0,-4.637 -3.763,-8.401 -8.4,-8.401z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M6,6v10c0,3.313 2.688,6 6,6s6,-2.688 6,-6h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5c0,-3.313 -2.688,-6 -6,-6s-6,2.688 -6,6zM20,15v1c0,4.419 -3.581,8 -8,8s-8,-3.581 -8,-8v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v2.5c0,5.569 4.138,10.169 9.5,10.9v2.1h-3c-0.831,0 -1.5,0.669 -1.5,1.5s0.669,1.5 1.5,1.5h9c0.831,0 1.5,-0.669 1.5,-1.5s-0.669,-1.5 -1.5,-1.5h-3v-2.1c5.363,-0.731 9.5,-5.331 9.5,-10.9v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v1.5z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="22dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="22"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M22.292,14.872c0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,2.977 -0.714,5.435 -2.159,7.076 -1.31,1.484 -3.289,2.341 -5.964,2.341s-4.654,-0.854 -5.964,-2.339c-1.448,-1.641 -2.159,-4.099 -2.159,-7.078 0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,3.711 0.961,6.857 2.914,9.073 1.438,1.63 3.375,2.734 5.815,3.164v2.479h-3.703c-0.661,0 -1.203,0.542 -1.203,1.203v1.206h14.646v-1.206c0,-0.661 -0.542,-1.203 -1.203,-1.203h-3.711v-2.479c2.44,-0.432 4.375,-1.534 5.815,-3.167 1.953,-2.214 2.917,-5.359 2.917,-9.07v0,0zM11.146,0c3.083,0 5.604,2.523 5.604,5.604v0.146h-3.013v3.57h3.016v1.818h-3.016v3.57h3.016v1.284c0,3.083 -2.523,5.604 -5.604,5.604 -3.083,0 -5.604,-2.523 -5.604,-5.604v-1.284h3.016v-3.57h-3.018v-1.818h3.016v-3.57h-3.016v-0.146c0,-3.081 2.521,-5.604 5.604,-5.604v0z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M15,22c2.761,0 5,-2.239 5,-5v-12c0,-2.761 -2.239,-5 -5,-5s-5,2.239 -5,5v12c0,2.761 2.239,5 5,5zM22,14v3c0,3.866 -3.134,7 -7,7s-7,-3.134 -7,-7v-3h-2v3c0,4.632 3.5,8.447 8,8.944v4.056h-4v2h10v-2h-4v-4.056c4.5,-0.497 8,-4.312 8,-8.944v-3h-2z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M30,0h2v23c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-11.501l-16,3.556v15.444c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-19.501l18,-4z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="33dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="33"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M18.289,18.549v13.45h-3.973v-13.45c-1.305,-0.706 -2.191,-2.087 -2.191,-3.676 0,-2.307 1.871,-4.178 4.178,-4.178s4.178,1.871 4.178,4.178c0,1.589 -0.887,2.97 -2.192,3.676v0zM10.434,2.963c0.774,-0.382 1.091,-1.319 0.709,-2.091s-1.318,-1.091 -2.091,-0.71c-3.158,1.558 -5.613,4.134 -7.165,7.169 -1.146,2.241 -1.8,4.734 -1.879,7.251 -0.079,2.538 0.421,5.101 1.585,7.46 1.433,2.906 3.863,5.491 7.44,7.322 0.767,0.392 1.706,0.088 2.098,-0.679s0.088,-1.706 -0.679,-2.097c-2.929,-1.498 -4.905,-3.589 -6.059,-5.926 -0.93,-1.887 -1.33,-3.942 -1.267,-5.981 0.064,-2.058 0.599,-4.098 1.536,-5.93 1.256,-2.455 3.234,-4.536 5.771,-5.786v0zM23.554,0.161c-0.773,-0.382 -1.71,-0.064 -2.091,0.71s-0.064,1.71 0.71,2.091c2.537,1.251 4.515,3.332 5.771,5.787 0.937,1.832 1.472,3.871 1.536,5.93 0.064,2.038 -0.336,4.094 -1.267,5.981 -1.153,2.337 -3.13,4.428 -6.059,5.926 -0.767,0.392 -1.07,1.331 -0.679,2.097s1.331,1.071 2.098,0.679c3.577,-1.83 6.007,-4.415 7.44,-7.322 1.164,-2.359 1.664,-4.922 1.585,-7.46 -0.079,-2.516 -0.732,-5.01 -1.878,-7.251 -1.552,-3.034 -4.008,-5.611 -7.165,-7.169v0zM21.968,7.033c-0.582,-0.42 -1.395,-0.287 -1.814,0.295s-0.287,1.395 0.295,1.814c0.235,0.169 0.465,0.359 0.691,0.566 1.319,1.216 2.101,2.838 2.266,4.553 0.166,1.731 -0.295,3.566 -1.464,5.188 -0.198,0.276 -0.427,0.555 -0.686,0.836 -0.487,0.529 -0.453,1.353 0.076,1.839s1.353,0.452 1.84,-0.076c0.313,-0.339 0.607,-0.701 0.88,-1.081 1.555,-2.16 2.167,-4.617 1.943,-6.951 -0.225,-2.349 -1.293,-4.566 -3.091,-6.224 -0.283,-0.261 -0.595,-0.514 -0.935,-0.76v0zM12.156,9.143c0.582,-0.42 0.715,-1.232 0.295,-1.814s-1.232,-0.715 -1.814,-0.296c-0.341,0.246 -0.652,0.499 -0.935,0.76 -1.799,1.658 -2.866,3.875 -3.091,6.224 -0.224,2.334 0.387,4.792 1.943,6.952 0.274,0.379 0.567,0.741 0.88,1.08 0.487,0.529 1.311,0.563 1.839,0.076s0.563,-1.311 0.076,-1.839c-0.259,-0.281 -0.487,-0.56 -0.686,-0.836 -1.169,-1.623 -1.63,-3.457 -1.464,-5.188 0.164,-1.715 0.947,-3.337 2.266,-4.553 0.226,-0.207 0.456,-0.397 0.691,-0.566v0z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M30.925,2.938c0.794,-0.231 1.25,-1.069 1.019,-1.863s-1.069,-1.25 -1.869,-1.012l-26.844,7.869c-0.587,0.169 -1.119,0.456 -1.569,0.825 -1.006,0.725 -1.663,1.906 -1.663,3.244v16c0,2.206 1.794,4 4,4h24c2.206,0 4,-1.794 4,-4v-16c0,-2.206 -1.794,-4 -4,-4h-14.344l17.269,-5.063zM23,25c-2.762,0 -5,-2.238 -5,-5s2.238,-5 5,-5 5,2.238 5,5 -2.238,5 -5,5zM5,16c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1zM4,20c0,-0.55 0.45,-1 1,-1h8c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-8c-0.55,0 -1,-0.45 -1,-1zM5,24c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M22,2l-10,10h-6l-6,8c0,0 6.357,-1.77 10.065,-0.94l-10.065,12.94 13.184,-10.255c1.839,4.208 -1.184,10.255 -1.184,10.255l8,-6v-6l10,-10 2,-10 -10,2z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M4.259,23.467c-2.35,0 -4.259,1.917 -4.259,4.252 0,2.349 1.909,4.244 4.259,4.244 2.358,0 4.265,-1.895 4.265,-4.244 -0,-2.336 -1.907,-4.252 -4.265,-4.252zM0.005,10.873v6.133c3.993,0 7.749,1.562 10.577,4.391 2.825,2.822 4.384,6.595 4.384,10.603h6.16c-0,-11.651 -9.478,-21.127 -21.121,-21.127zM0.012,0v6.136c14.243,0 25.836,11.604 25.836,25.864h6.152c0,-17.64 -14.352,-32 -31.988,-32z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:pathData="M32,12.408l-11.056,-1.607 -4.944,-10.018 -4.944,10.018 -11.056,1.607 8,7.798 -1.889,11.011 9.889,-5.199 9.889,5.199 -1.889,-11.011 8,-7.798z"
|
||||||
|
android:fillColor="#fff"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/account_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" /></vector>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/book_multiple_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M19 2A2 2 0 0 1 21 4V16A2 2 0 0 1 19 18H9A2 2 0 0 1 7 16V4A2 2 0 0 1 9 2H19M19 4H16V10L13.5 7.75L11 10V4H9V16H19M3 20A2 2 0 0 0 5 22H17V20H5V6H3Z" /></vector>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/book_open_blank_variant_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12 21.5C10.65 20.65 8.2 20 6.5 20C4.85 20 3.15 20.3 1.75 21.05C1.65 21.1 1.6 21.1 1.5 21.1C1.25 21.1 1 20.85 1 20.6V6C1.6 5.55 2.25 5.25 3 5C4.11 4.65 5.33 4.5 6.5 4.5C8.45 4.5 10.55 4.9 12 6C13.45 4.9 15.55 4.5 17.5 4.5C18.67 4.5 19.89 4.65 21 5C21.75 5.25 22.4 5.55 23 6V20.6C23 20.85 22.75 21.1 22.5 21.1C22.4 21.1 22.35 21.1 22.25 21.05C20.85 20.3 19.15 20 17.5 20C15.8 20 13.35 20.65 12 21.5M11 7.5C9.64 6.9 7.84 6.5 6.5 6.5C5.3 6.5 4.1 6.65 3 7V18.5C4.1 18.15 5.3 18 6.5 18C7.84 18 9.64 18.4 11 19V7.5M13 19C14.36 18.4 16.16 18 17.5 18C18.7 18 19.9 18.15 21 18.5V7C19.9 6.65 18.7 6.5 17.5 6.5C16.16 6.5 14.36 6.9 13 7.5V19Z" /></vector>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- drawable/telescope.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M21.9,8.9L20.2,9.9L16.2,3L17.9,2L21.9,8.9M9.8,7.9L12.8,13.1L18.9,9.6L15.9,4.4L9.8,7.9M11.4,12.7L9.4,9.2L5.1,11.7L7.1,15.2L11.4,12.7M2.1,14.6L3.1,16.3L5.7,14.8L4.7,13.1L2.1,14.6M12.1,14L11.8,13.6L7.5,16.1L7.8,16.5C8,16.8 8.3,17.1 8.6,17.3L7,22H9L10.4,17.7H10.5L12,22H14L12.1,16.4C12.6,15.7 12.6,14.8 12.1,14Z" /></vector>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
tools:context=".MainActivity">
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
<WebView
|
<WebView
|
||||||
|
android:id="@+id/webview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@color/background_dark</item>
|
||||||
<item name="android:statusBarColor">@color/background_dark</item>
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
<color name="light_blue_200">#FF81D4FA</color>
|
<color name="light_blue_200">#FF81D4FA</color>
|
||||||
<color name="light_blue_600">#FF039BE5</color>
|
<color name="light_blue_600">#FF039BE5</color>
|
||||||
<color name="light_blue_900">#FF01579B</color>
|
<color name="light_blue_900">#FF01579B</color>
|
||||||
<color name="background_dark">#262626</color>
|
<color name="background_dark">#232323</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@color/background_dark</item>
|
||||||
<item name="android:statusBarColor">@color/background_dark</item>
|
<item name="android:statusBarColor">@color/background_dark</item>
|
||||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,4 +11,4 @@
|
|||||||
android:targetCellWidth="4"
|
android:targetCellWidth="4"
|
||||||
android:targetCellHeight="1"
|
android:targetCellHeight="1"
|
||||||
android:updatePeriodMillis="86400000"
|
android:updatePeriodMillis="86400000"
|
||||||
android:widgetCategory="home_screen" />
|
android:widgetCategory="home_screen|keyguard" />
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<samsung-appwidget-provider
|
||||||
|
display="sub_screen"
|
||||||
|
privacyWidget="false" />
|
||||||