Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 |
@@ -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
|
||||||
|
|||||||
@@ -72,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.79'
|
- 'Android App - 0.13.0'
|
||||||
- 'iOS App - 0.9.79'
|
- 'iOS App - 0.13.0'
|
||||||
- 'Android App - 0.9.78'
|
- 'Android App - 0.12.0'
|
||||||
- 'iOS App - 0.9.78'
|
- 'iOS App - 0.12.0'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -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.79
|
- 'Android App - 0.13.0'
|
||||||
- iOS App - 0.9.79
|
- 'iOS App - 0.13.0'
|
||||||
- Android App - 0.9.78
|
- 'Android App - 0.12.0'
|
||||||
- iOS App - 0.9.78
|
- '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. -->
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ android {
|
|||||||
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 110
|
versionCode 117
|
||||||
versionName "0.9.79-beta"
|
versionName "0.13.0-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
@@ -92,6 +92,7 @@ dependencies {
|
|||||||
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.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"
|
||||||
|
|||||||
@@ -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 -->
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ data class AudioTrack(
|
|||||||
var metadata: FileMetadata?,
|
var metadata: FileMetadata?,
|
||||||
var isLocal: Boolean,
|
var isLocal: Boolean,
|
||||||
var localFileId: String?,
|
var localFileId: String?,
|
||||||
|
// 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
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +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 {
|
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||||
ASC, DESC
|
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"
|
||||||
@@ -78,119 +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 androidAutoBrowseLimitForGrouping: Int,
|
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
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,
|
||||||
androidAutoBrowseLimitForGrouping = 100,
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
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 {
|
||||||
@@ -201,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? {
|
||||||
@@ -213,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
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ 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}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ data class LocalMediaItem(
|
|||||||
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>,
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ class PlaybackSession(
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
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
|
@JsonIgnore
|
||||||
fun getCoverUri(ctx: Context): Uri {
|
fun getCoverUri(ctx: Context): Uri {
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
@@ -168,12 +178,27 @@ class PlaybackSession(
|
|||||||
|
|
||||||
if (coverPath == null)
|
if (coverPath == null)
|
||||||
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 (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}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,14 +289,16 @@ class PlaybackSession(
|
|||||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
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(
|
castMetadata.addImage(WebImage(coverUri))
|
||||||
WebImage(
|
|
||||||
Uri.parse(
|
|
||||||
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||||
|
|||||||
@@ -32,14 +32,16 @@ object DeviceManager {
|
|||||||
var deviceData: DeviceData = dbManager.getDeviceData()
|
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||||
|
|
||||||
val serverConnectionConfigId
|
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||||
get() = serverConnectionConfig?.id ?: ""
|
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||||
|
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||||
val serverAddress
|
val serverAddress
|
||||||
get() = serverConnectionConfig?.address ?: ""
|
get() = serverConnectionConfig?.address ?: ""
|
||||||
val serverUserId
|
val serverUserId
|
||||||
get() = serverConnectionConfig?.userId ?: ""
|
get() = serverConnectionConfig?.userId ?: ""
|
||||||
val token
|
val token
|
||||||
get() = serverConnectionConfig?.token ?: ""
|
get() = serverConnectionConfig?.token ?: ""
|
||||||
|
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||||
val isConnectedToServer
|
val isConnectedToServer
|
||||||
get() = serverConnectionConfig != null
|
get() = serverConnectionConfig != null
|
||||||
|
|
||||||
@@ -63,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) {
|
||||||
@@ -106,6 +112,41 @@ object DeviceManager {
|
|||||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Checks the network connectivity status.
|
||||||
* @param ctx The context to use for checking connectivity.
|
* @param ctx The context to use for checking connectivity.
|
||||||
|
|||||||
@@ -7,570 +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(
|
data class DownloadItemScanResult(
|
||||||
val localLibraryItem: LocalLibraryItem,
|
val localLibraryItem: LocalLibraryItem,
|
||||||
var localMediaProgress: LocalMediaProgress?
|
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(
|
private fun createLocalFile(
|
||||||
downloadItem: DownloadItem,
|
part: DownloadItemPart,
|
||||||
cb: (DownloadItemScanResult?) -> Unit
|
externalFile: DocumentFile? = null
|
||||||
) {
|
): LocalFile? {
|
||||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
if (part.isInternalStorage) {
|
||||||
|
val file = File(part.finalDestinationPath)
|
||||||
var localEpisodeId: String? = null
|
if (!file.exists()) return null
|
||||||
var localLibraryItem: LocalLibraryItem?
|
return LocalFile(
|
||||||
if (downloadItem.mediaType == "book") {
|
DeviceManager.getBase64Id(file.name),
|
||||||
localLibraryItem =
|
file.name,
|
||||||
LocalLibraryItem(
|
Uri.fromFile(file).toString(),
|
||||||
localLibraryItemId,
|
file.getBasePath(ctx),
|
||||||
downloadItem.localFolder.id,
|
file.absolutePath,
|
||||||
downloadItem.itemFolderPath,
|
file.mimeType,
|
||||||
downloadItem.itemFolderPath,
|
file.length()
|
||||||
"",
|
)
|
||||||
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,
|
|
||||||
downloadItem.itemFolderPath,
|
|
||||||
downloadItem.itemFolderPath,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
downloadItem.mediaType,
|
|
||||||
downloadItem.media.getLocalCopy(),
|
|
||||||
mutableListOf(),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
downloadItem.serverConnectionConfigId,
|
|
||||||
downloadItem.serverAddress,
|
|
||||||
downloadItem.serverUserId,
|
|
||||||
downloadItem.libraryItemId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
part.completedDestinationUri?.let { contentUrl ->
|
||||||
var foundEBookFile = false
|
val uri = Uri.parse(contentUrl)
|
||||||
|
val size =
|
||||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
try {
|
||||||
Log.d(
|
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
tag,
|
descriptor.statSize.coerceAtLeast(0L)
|
||||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
}
|
||||||
|
?: 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
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
// 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (file == null) {
|
private fun newLocalLibraryItem(
|
||||||
Log.e(
|
id: String,
|
||||||
tag,
|
downloadItem: DownloadItem,
|
||||||
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
|
basePath: String,
|
||||||
)
|
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 =
|
localItem: LocalLibraryItem,
|
||||||
LocalFile(
|
externalFolder: DocumentFile? = null,
|
||||||
localFileId,
|
callback: (DownloadItemScanResult?) -> Unit
|
||||||
file.name,
|
) {
|
||||||
downloadItemPart.finalDestinationUri.toString(),
|
val tracks = mutableListOf<AudioTrack>()
|
||||||
file.getBasePath(ctx),
|
var foundEbook = false
|
||||||
file.absolutePath,
|
var localEpisodeId: String? = null
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
|
||||||
file.length()
|
|
||||||
)
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
val trackFileMetadata =
|
item.downloadItemParts.forEach { part ->
|
||||||
|
val externalFile =
|
||||||
|
if (part.isInternalStorage) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
|
||||||
|
?: resolveExternalFile(externalFolder, part)
|
||||||
|
}
|
||||||
|
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
|
||||||
|
val localFile = createLocalFile(part, externalFile) ?: return@forEach
|
||||||
|
when {
|
||||||
|
part.audioTrack != null -> {
|
||||||
|
val serverTrack = part.audioTrack
|
||||||
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
|
localItem.localFiles.add(localFile)
|
||||||
|
val metadata =
|
||||||
FileMetadata(
|
FileMetadata(
|
||||||
file.name,
|
localFile.filename ?: "",
|
||||||
file.extension,
|
File(localFile.filename ?: "").extension,
|
||||||
file.absolutePath,
|
localFile.absolutePath,
|
||||||
file.getBasePath(ctx),
|
localFile.basePath,
|
||||||
file.length()
|
localFile.size
|
||||||
)
|
)
|
||||||
// Create new audio track
|
|
||||||
val track =
|
val track =
|
||||||
AudioTrack(
|
AudioTrack(
|
||||||
audioTrackFromServer.index,
|
serverTrack.index,
|
||||||
audioTrackFromServer.startOffset,
|
serverTrack.startOffset,
|
||||||
audioTrackFromServer.duration,
|
serverTrack.duration,
|
||||||
localFile.filename ?: "",
|
localFile.filename ?: "",
|
||||||
localFile.contentUrl,
|
localFile.contentUrl,
|
||||||
localFile.mimeType ?: "",
|
localFile.mimeType ?: "",
|
||||||
trackFileMetadata,
|
metadata,
|
||||||
true,
|
true,
|
||||||
localFileId,
|
localFile.id,
|
||||||
audioTrackFromServer.index
|
serverTrack.index
|
||||||
)
|
)
|
||||||
audioTracks.add(track)
|
tracks.add(track)
|
||||||
|
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||||
Log.d(
|
part.episode?.let { episode ->
|
||||||
tag,
|
val podcast = localItem.media as Podcast
|
||||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||||
)
|
|
||||||
|
|
||||||
// Add podcast episodes to library
|
|
||||||
downloadItemPart.episode?.let { podcastEpisode ->
|
|
||||||
val podcast = localLibraryItem.media as Podcast
|
|
||||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
|
||||||
localEpisodeId = newEpisode.id
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if (downloadItemPart.ebookFile != null) {
|
}
|
||||||
foundEBookFile = true
|
part.ebookFile != null -> {
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
foundEbook = true
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
val localFile =
|
localItem.localFiles.add(localFile)
|
||||||
LocalFile(
|
(localItem.media as Book).ebookFile =
|
||||||
localFileId,
|
|
||||||
file.name,
|
|
||||||
Uri.fromFile(file).toString(),
|
|
||||||
file.getBasePath(ctx),
|
|
||||||
file.absolutePath,
|
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
|
||||||
file.length()
|
|
||||||
)
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
val ebookFile =
|
|
||||||
EBookFile(
|
EBookFile(
|
||||||
downloadItemPart.ebookFile.ino,
|
part.ebookFile.ino,
|
||||||
downloadItemPart.ebookFile.metadata,
|
part.ebookFile.metadata,
|
||||||
downloadItemPart.ebookFile.ebookFormat,
|
part.ebookFile.ebookFormat,
|
||||||
true,
|
true,
|
||||||
localFileId,
|
localFile.id,
|
||||||
localFile.contentUrl
|
localFile.contentUrl
|
||||||
)
|
)
|
||||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
}
|
||||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
else -> {
|
||||||
} else {
|
localItem.coverAbsolutePath = localFile.absolutePath
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
localItem.coverContentUrl = localFile.contentUrl
|
||||||
val localFile =
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
LocalFile(
|
localItem.localFiles.add(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(
|
callback(null)
|
||||||
tag,
|
return
|
||||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
|
||||||
)
|
|
||||||
return cb(null)
|
|
||||||
}
|
}
|
||||||
|
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 =
|
else "${localItem.id}-$localEpisodeId"
|
||||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
result.localMediaProgress =
|
||||||
else "$localLibraryItemId-$localEpisodeId"
|
|
||||||
val newLocalMediaProgress =
|
|
||||||
LocalMediaProgress(
|
LocalMediaProgress(
|
||||||
id = localMediaProgressId,
|
progressId,
|
||||||
localLibraryItemId = localLibraryItemId,
|
localItem.id,
|
||||||
localEpisodeId = localEpisodeId,
|
localEpisodeId,
|
||||||
duration = mediaProgress.duration,
|
progress.duration,
|
||||||
progress = mediaProgress.progress,
|
progress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
progress.currentTime,
|
||||||
isFinished = mediaProgress.isFinished,
|
progress.isFinished,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
progress.ebookLocation,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
progress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
progress.lastUpdate,
|
||||||
startedAt = mediaProgress.startedAt,
|
progress.startedAt,
|
||||||
finishedAt = mediaProgress.finishedAt,
|
progress.finishedAt,
|
||||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
item.serverConnectionConfigId,
|
||||||
serverAddress = downloadItem.serverAddress,
|
item.serverAddress,
|
||||||
serverUserId = downloadItem.serverUserId,
|
item.serverUserId,
|
||||||
libraryItemId = downloadItem.libraryItemId,
|
item.libraryItemId,
|
||||||
episodeId = downloadItem.episodeId
|
item.episodeId
|
||||||
)
|
)
|
||||||
Log.d(
|
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||||
tag,
|
|
||||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
|
||||||
)
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
|
||||||
|
|
||||||
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(
|
itemFolder.uri.toString()
|
||||||
tag,
|
)
|
||||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
scanParts(item, localItem, itemFolder, callback)
|
||||||
)
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
// 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,
|
|
||||||
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"
|
||||||
@@ -146,7 +147,7 @@ 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 ->
|
||||||
@@ -155,15 +156,15 @@ class DbManager {
|
|||||||
// Check local files
|
// Check local files
|
||||||
lli.localFiles =
|
lli.localFiles =
|
||||||
lli.localFiles.filter { localFile ->
|
lli.localFiles.filter { localFile ->
|
||||||
val file = File(localFile.absolutePath)
|
val exists = localFile.exists(context)
|
||||||
if (!file.exists()) {
|
if (!exists) {
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||||
)
|
)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
file.exists()
|
exists
|
||||||
} as
|
} as
|
||||||
MutableList<LocalFile>
|
MutableList<LocalFile>
|
||||||
|
|
||||||
@@ -201,9 +202,11 @@ class DbManager {
|
|||||||
|
|
||||||
// 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)
|
||||||
|
}
|
||||||
|
if (!coverExists) {
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||||
@@ -287,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
|
||||||
@@ -19,41 +13,44 @@ 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.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileInputStream
|
||||||
import java.util.*
|
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 okhttp3.Call
|
||||||
|
|
||||||
/** Manages download items and their parts. */
|
/** Manages the process-owned queue for app-managed downloads. */
|
||||||
class DownloadItemManager(
|
class DownloadItemManager(
|
||||||
var downloadManager: DownloadManager,
|
private val folderScanner: FolderScanner,
|
||||||
private var folderScanner: FolderScanner,
|
private val context: Context,
|
||||||
var mainActivity: MainActivity,
|
|
||||||
private var clientEventEmitter: DownloadEventEmitter
|
private var clientEventEmitter: DownloadEventEmitter
|
||||||
) {
|
) {
|
||||||
val tag = "DownloadItemManager"
|
private val tag = "DownloadItemManager"
|
||||||
private val maxSimultaneousDownloads = 3
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var jacksonMapper =
|
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()
|
jacksonObjectMapper()
|
||||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
.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 {
|
||||||
@@ -61,323 +58,455 @@ class DownloadItemManager(
|
|||||||
fun onComplete(failed: Boolean)
|
fun onComplete(failed: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
init {
|
||||||
var isDownloading: Boolean = false
|
IncompleteDownloadCleanup.cleanupExpired(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a download item to the queue and starts processing the queue. */
|
@Synchronized
|
||||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
clientEventEmitter = eventEmitter
|
||||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks and updates the download queue. */
|
private fun retryDownloadItem(item: DownloadItem) {
|
||||||
|
item.terminalFailureAt = null
|
||||||
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
|
item.downloadItemParts.filter { it.failed }.forEach { part ->
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.isMoving = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.retryCount = 0
|
||||||
|
}
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun hasWork(): Boolean =
|
||||||
|
downloadItemQueue.any { item ->
|
||||||
|
item.downloadItemParts.any { part ->
|
||||||
|
(!part.completed && !part.failed) || part.isMoving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
private fun checkUpdateDownloadQueue() {
|
private fun checkUpdateDownloadQueue() {
|
||||||
for (downloadItem in downloadItemQueue) {
|
downloadItemQueue.toList().forEach { item ->
|
||||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
if (slots <= 0) return@forEach
|
||||||
Log.d(
|
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||||
tag,
|
val existingFile = findSharedStorageFile(part)
|
||||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
if (existingFile != null) {
|
||||||
)
|
part.bytesDownloaded = existingFile.length()
|
||||||
|
part.progress = 100L
|
||||||
if (nextDownloadItemParts.isNotEmpty()) {
|
part.completedDestinationUri = existingFile.uri.toString()
|
||||||
processDownloadItemParts(nextDownloadItemParts)
|
File(part.destinationPath).delete()
|
||||||
}
|
completePart(item, part)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
} else if (tryReserve(part)) startDownload(item, part)
|
||||||
break
|
else {
|
||||||
|
part.waitingForSpace = true
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Processes the download item parts. */
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
val stagingFile = File(part.destinationPath)
|
||||||
nextDownloadItemParts.forEach {
|
stagingFile.parentFile?.mkdirs()
|
||||||
if (it.isInternalStorage) {
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
startInternalDownload(it)
|
part.waitingForSpace = false
|
||||||
} else {
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
startExternalDownload(it)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts an internal download. */
|
@Synchronized
|
||||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
|
||||||
val internalProgressCallback =
|
|
||||||
object : InternalProgressCallback {
|
|
||||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
|
||||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
|
||||||
downloadItemPart.progress = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onComplete(failed: Boolean) {
|
|
||||||
downloadItemPart.failed = failed
|
|
||||||
downloadItemPart.completed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
|
||||||
)
|
|
||||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
|
||||||
.download(downloadItemPart.serverUrl)
|
|
||||||
downloadItemPart.downloadId = 1
|
|
||||||
currentDownloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Starts an external download. */
|
|
||||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
|
||||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
|
||||||
currentDownloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Starts watching the downloads. */
|
|
||||||
private fun startWatchingDownloads() {
|
private fun startWatchingDownloads() {
|
||||||
if (isDownloading) return // Already watching
|
if (watcherRunning) return
|
||||||
|
watcherRunning = true
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
scope.launch {
|
||||||
Log.d(tag, "Starting watching downloads")
|
while (true) {
|
||||||
isDownloading = true
|
val activeParts =
|
||||||
|
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||||
while (currentDownloadItemParts.isNotEmpty()) {
|
activeParts.forEach(::handlePartUpdate)
|
||||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
synchronized(this@DownloadItemManager) {
|
||||||
for (downloadItemPart in itemParts) {
|
|
||||||
if (downloadItemPart.isInternalStorage) {
|
|
||||||
handleInternalDownloadPart(downloadItemPart)
|
|
||||||
} else {
|
|
||||||
handleExternalDownloadPart(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(500)
|
|
||||||
|
|
||||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
|
||||||
checkUpdateDownloadQueue()
|
checkUpdateDownloadQueue()
|
||||||
}
|
if (!hasWork()) {
|
||||||
}
|
watcherRunning = false
|
||||||
|
notifyQueueChanged()
|
||||||
Log.d(tag, "Finished watching downloads")
|
return@launch
|
||||||
isDownloading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles an internal download part. */
|
|
||||||
private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
|
||||||
|
|
||||||
if (downloadItemPart.completed) {
|
|
||||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
|
||||||
downloadItem?.let { checkDownloadItemFinished(it) }
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handles an external download part. */
|
|
||||||
private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks the status of a download item part. */
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handles the result of a download item part check. */
|
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||||
private fun handleDownloadItemPartCheck(
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
downloadCheckStatus: DownloadCheckStatus,
|
val item =
|
||||||
downloadItemPart: DownloadItemPart
|
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||||
) {
|
?: run {
|
||||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
removeActivePart(part)
|
||||||
if (downloadItem == null) {
|
return
|
||||||
Log.e(
|
}
|
||||||
tag,
|
if (!part.completed && !part.failed) {
|
||||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
val lastUpdate = part.lastUpdateTime ?: return
|
||||||
)
|
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
Log.w(tag, "Download stalled: ${part.filename}")
|
||||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
activeCalls.remove(part.id)?.cancel()
|
||||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
failOrRetry(item, part, "Download stalled")
|
||||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
}
|
||||||
checkDownloadItemFinished(downloadItem)
|
return
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
}
|
||||||
|
if (part.failed) {
|
||||||
|
failOrRetry(item, part, "Transfer failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||||
|
removeActivePart(part)
|
||||||
|
part.retryCount += 1
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
|
Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}")
|
||||||
|
part.failed = true
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||||
|
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()
|
||||||
|
completePart(item, part)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves the downloaded file to its final destination. */
|
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
if (part.moved || part.isMoving) return
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
val root =
|
||||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val fcb =
|
@Synchronized
|
||||||
object : FileCallback() {
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
override fun onPrepare() {
|
Log.e(tag, message)
|
||||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
part.isMoving = false
|
||||||
}
|
part.failed = true
|
||||||
|
failOrRetry(item, part, message)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onFailed(errorCode: ErrorCode) {
|
@Synchronized
|
||||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
downloadItemPart.failed = true
|
part.moved = true
|
||||||
downloadItemPart.isMoving = false
|
part.completed = true
|
||||||
file?.delete()
|
part.failed = false
|
||||||
checkDownloadItemFinished(downloadItem)
|
part.isMoving = false
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
reservations.remove(part.destinationPath)
|
||||||
}
|
removeActivePart(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCompleted(result: Any) {
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
if (!item.isDownloadFinished) return
|
||||||
val resultDocFile = result as DocumentFile
|
scope.launch {
|
||||||
Log.d(
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
tag,
|
val event =
|
||||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
JSObject().apply {
|
||||||
)
|
put("libraryItemId", item.id)
|
||||||
|
put("localFolderId", item.localFolder.id)
|
||||||
// Rename to fix appended .mp3 on m4b/m4a files
|
scanResult?.localLibraryItem?.let {
|
||||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
}
|
||||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
scanResult?.localMediaProgress?.let {
|
||||||
) {
|
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
resultDocFile.renameTo(downloadItemPart.filename)
|
}
|
||||||
}
|
}
|
||||||
|
clientEventEmitter.onDownloadItemComplete(event)
|
||||||
downloadItemPart.moved = true
|
synchronized(this@DownloadItemManager) {
|
||||||
downloadItemPart.isMoving = false
|
downloadItemQueue.remove(item)
|
||||||
checkDownloadItemFinished(downloadItem)
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
notifyQueueChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val localFolderFile =
|
|
||||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
|
||||||
if (localFolderFile == null) {
|
|
||||||
// Failed
|
|
||||||
downloadItemPart.failed = true
|
|
||||||
Log.e(tag, "Local Folder File from uri is null")
|
|
||||||
checkDownloadItemFinished(downloadItem)
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
} else {
|
|
||||||
downloadItemPart.isMoving = true
|
|
||||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
|
||||||
val fileDescription =
|
|
||||||
FileDescription(
|
|
||||||
downloadItemPart.filename,
|
|
||||||
downloadItemPart.finalDestinationSubfolder,
|
|
||||||
mimetype
|
|
||||||
)
|
|
||||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checks if a download item is finished and processes it. */
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||||
if (downloadItem.isDownloadFinished) {
|
val staging = File(part.destinationPath)
|
||||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
val staging = File(part.destinationPath)
|
||||||
Log.d(
|
val fs = statFsFor(staging)
|
||||||
tag,
|
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
}
|
||||||
)
|
|
||||||
|
|
||||||
val jsobj =
|
private fun statFsFor(staging: File): StatFs {
|
||||||
JSObject().apply {
|
var directory = staging.parentFile ?: context.filesDir
|
||||||
put("libraryItemId", downloadItem.id)
|
directory.mkdirs()
|
||||||
put("localFolderId", downloadItem.localFolder.id)
|
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||||
|
return StatFs(directory.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
private fun storageKey(file: File): String =
|
||||||
put(
|
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
|
||||||
"localLibraryItem",
|
else "external"
|
||||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
|
||||||
)
|
@Synchronized
|
||||||
}
|
private fun removeActivePart(part: DownloadItemPart) {
|
||||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
activeCalls.remove(part.id)
|
||||||
put(
|
currentDownloadItemParts.remove(part)
|
||||||
"localMediaProgress",
|
}
|
||||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
|
||||||
)
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
downloadItemQueue.remove(downloadItem)
|
return "${item.serverAddress}${part.serverPath}$rawCover"
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
}
|
||||||
}
|
|
||||||
}
|
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,114 +1,135 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.*
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import okhttp3.*
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
/**
|
/** Streams a download into an app-owned staging file. */
|
||||||
* Manages the internal download process.
|
|
||||||
*
|
|
||||||
* @property outputStream The output stream to write the downloaded data.
|
|
||||||
* @property progressCallback The callback to report download progress.
|
|
||||||
*/
|
|
||||||
class InternalDownloadManager(
|
class InternalDownloadManager(
|
||||||
private val outputStream: FileOutputStream,
|
private val destinationFile: File,
|
||||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
private val expectedSize: Long,
|
||||||
) : AutoCloseable {
|
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||||
|
private val hasAvailableSpace: () -> Boolean
|
||||||
|
) {
|
||||||
private val tag = "InternalDownloadManager"
|
private val tag = "InternalDownloadManager"
|
||||||
private val client: OkHttpClient =
|
|
||||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
|
||||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a file from the given URL.
|
* Starts or resumes a download.
|
||||||
*
|
*
|
||||||
* @param url The URL to download the file from.
|
* @param url download URL
|
||||||
* @throws IOException If an I/O error occurs.
|
* @param token access token sent in the Authorization header
|
||||||
|
* @return active call, used to cancel a stalled transfer
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
fun download(url: String, token: String): Call {
|
||||||
fun download(url: String) {
|
destinationFile.parentFile?.mkdirs()
|
||||||
val request: Request = Request.Builder().url(url).build()
|
val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||||
client.newCall(request)
|
val request =
|
||||||
.enqueue(
|
Request.Builder()
|
||||||
object : Callback {
|
.url(url)
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
.addHeader("Accept-Encoding", "identity")
|
||||||
Log.e(tag, "Download URL $url FAILED", e)
|
.addHeader("Authorization", "Bearer $token")
|
||||||
progressCallback.onComplete(true)
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.body?.let { responseBody ->
|
response.use {
|
||||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
try {
|
||||||
writer.write(responseBody.byteStream(), length)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val startingBytes = if (append) existingBytes else 0L
|
||||||
|
val responseLength = response.body!!.contentLength()
|
||||||
|
val totalLength =
|
||||||
|
if (expectedSize > 0L) expectedSize
|
||||||
|
else if (responseLength >= 0L) startingBytes + responseLength else 0L
|
||||||
|
|
||||||
|
FileOutputStream(destinationFile, append).use { output ->
|
||||||
|
response.body!!.byteStream().use { input ->
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
?: run {
|
|
||||||
Log.e(tag, "Response doesn't contain a file")
|
|
||||||
progressCallback.onComplete(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if (expectedSize > 0L && destinationFile.length() != expectedSize) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||||
* Closes the download manager and releases resources.
|
val range = response.header("Content-Range") ?: return false
|
||||||
*
|
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||||
* @throws Exception If an error occurs during closing.
|
return match.groupValues[1].toLongOrNull() == offset &&
|
||||||
*/
|
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||||
@Throws(Exception::class)
|
}
|
||||||
override fun close() {
|
|
||||||
writer.close()
|
private companion object {
|
||||||
}
|
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||||
}
|
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)")
|
||||||
|
val client =
|
||||||
/**
|
OkHttpClient.Builder()
|
||||||
* Writes binary data to an output stream.
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
*
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
* @property outputStream The output stream to write the data to.
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
* @property progressCallback The callback to report write progress.
|
.build()
|
||||||
*/
|
|
||||||
class BinaryFileWriter(
|
|
||||||
private val outputStream: OutputStream,
|
|
||||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
|
||||||
) : AutoCloseable {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes data from the input stream to the output stream.
|
|
||||||
*
|
|
||||||
* @param inputStream The input stream to read the data from.
|
|
||||||
* @param length The total length of the data to be written.
|
|
||||||
* @return The total number of bytes written.
|
|
||||||
* @throws IOException If an I/O error occurs.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun write(inputStream: InputStream, length: Long): Long {
|
|
||||||
BufferedInputStream(inputStream).use { input ->
|
|
||||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
|
||||||
var totalBytes: Long = 0
|
|
||||||
var readBytes: Int
|
|
||||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
|
||||||
totalBytes += readBytes
|
|
||||||
outputStream.write(dataBuffer, 0, readBytes)
|
|
||||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
|
||||||
}
|
|
||||||
progressCallback.onComplete(false)
|
|
||||||
return totalBytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes the writer and releases resources.
|
|
||||||
*
|
|
||||||
* @throws IOException If an error occurs during closing.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
override fun close() {
|
|
||||||
outputStream.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +1,20 @@
|
|||||||
package com.audiobookshelf.app.managers
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
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 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
|
||||||
|
|
||||||
|
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||||
|
|
||||||
class SleepTimerManager
|
class SleepTimerManager
|
||||||
constructor(private val playerNotificationService: PlayerNotificationService) {
|
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||||
private val tag = "SleepTimerManager"
|
private val tag = "SleepTimerManager"
|
||||||
@@ -156,6 +161,10 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||||
|
playChimeSound()
|
||||||
|
}
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining <= 0) {
|
if (sleepTimeSecondsRemaining <= 0) {
|
||||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||||
pause()
|
pause()
|
||||||
@@ -263,6 +272,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plays chime sound */
|
||||||
|
private fun playChimeSound() {
|
||||||
|
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
|
* 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.
|
* the chapter, then use the next chapter.
|
||||||
@@ -506,4 +527,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
|||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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"
|
||||||
@@ -136,7 +137,6 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
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) {
|
||||||
Log.d(tag, "Reset caches")
|
|
||||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||||
serverLibraries = listOf()
|
serverLibraries = listOf()
|
||||||
serverLibraryItems = mutableListOf()
|
serverLibraryItems = mutableListOf()
|
||||||
@@ -176,21 +176,20 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
* Load personalized shelves from server for all libraries.
|
* Load personalized shelves from server for all libraries.
|
||||||
* [cb] resolves when all libraries are processed
|
* [cb] resolves when all libraries are processed
|
||||||
*/
|
*/
|
||||||
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) {
|
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
||||||
serverLibraries.forEach {
|
val remaining = AtomicInteger(serverLibraries.size)
|
||||||
libraryPersonalizationsDone++
|
|
||||||
Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
serverLibraries.forEach { lib ->
|
||||||
populatePersonalizedDataForLibrary(it.id) {
|
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||||
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
populatePersonalizedDataForLibrary(lib.id) {
|
||||||
libraryPersonalizationsDone--
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while (libraryPersonalizationsDone > 0) { }
|
|
||||||
|
|
||||||
Log.d(tag, "Finished loading all library personalization data")
|
|
||||||
allLibraryPersonalizationsDone = true
|
|
||||||
cb()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
@@ -208,6 +209,7 @@ class MediaProgressSyncer(
|
|||||||
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
|
||||||
@@ -260,44 +262,46 @@ class MediaProgressSyncer(
|
|||||||
tag,
|
tag,
|
||||||
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
"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
|
// Send sync to server also if connected to this server and local item belongs to this
|
||||||
// server
|
// server
|
||||||
|
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||||
if (hasNetworkConnection &&
|
if (hasNetworkConnection &&
|
||||||
shouldSyncServer &&
|
shouldSyncServer &&
|
||||||
!it.libraryItemId.isNullOrEmpty() &&
|
!it.libraryItemId.isNullOrEmpty() &&
|
||||||
it.serverConnectionConfigId != null &&
|
isConnectedToSameServer
|
||||||
DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
|
||||||
) {
|
) {
|
||||||
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(
|
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
tag,
|
|
||||||
"Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -308,14 +312,12 @@ class MediaProgressSyncer(
|
|||||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
}
|
}
|
||||||
Log.e(
|
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||||
tag,
|
|
||||||
"Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,28 +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 += "&raw=1" // Download raw cover image
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -68,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
|
||||||
)
|
)
|
||||||
@@ -79,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() = uri.toString()
|
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
@@ -15,7 +14,7 @@ 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 +35,17 @@ 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
|
||||||
|
|
||||||
|
// 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) {
|
||||||
@@ -61,7 +65,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
currentBitmap
|
currentBitmap
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import android.support.v4.media.MediaDescriptionCompat
|
|||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import androidx.media.VolumeProviderCompat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
@@ -36,6 +38,7 @@ import com.audiobookshelf.app.media.MediaManager
|
|||||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||||
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
||||||
import com.audiobookshelf.app.media.getUriToDrawable
|
import com.audiobookshelf.app.media.getUriToDrawable
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
@@ -93,6 +96,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||||
lateinit var mediaSession: MediaSessionCompat
|
lateinit var mediaSession: MediaSessionCompat
|
||||||
|
private var remoteVolumeProvider: VolumeProviderCompat? = null
|
||||||
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
||||||
|
|
||||||
lateinit var mediaManager: MediaManager
|
lateinit var mediaManager: MediaManager
|
||||||
@@ -310,17 +314,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||||
|
|
||||||
|
|
||||||
var bitmap: Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
|
// Note: In Android Auto for local cover images, setting the icon uri to a local path does not work (cover is blank)
|
||||||
|
// so we create and set the bitmap here instead of AbMediaDescriptionAdapter
|
||||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||||
bitmap =
|
bitmap =
|
||||||
if (Build.VERSION.SDK_INT < 28) {
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
} else {
|
} else {
|
||||||
val source: ImageDecoder.Source =
|
val source: ImageDecoder.Source =
|
||||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
ImageDecoder.decodeBitmap(source)
|
ImageDecoder.decodeBitmap(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix for local images crashing on Android 11 for specific devices
|
// Fix for local images crashing on Android 11 for specific devices
|
||||||
@@ -347,7 +354,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||||
|
|
||||||
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
|
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
|
||||||
?: mediaDescriptionBuilder.setIconUri(coverUri)
|
?: mediaDescriptionBuilder.setIconUri(coverUri)
|
||||||
|
|
||||||
return mediaDescriptionBuilder.build()
|
return mediaDescriptionBuilder.build()
|
||||||
}
|
}
|
||||||
@@ -452,7 +459,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playbackSession
|
playbackSession
|
||||||
) // Save playback session to use when app is closed
|
) // Save playback session to use when app is closed
|
||||||
|
|
||||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||||
// Notify client
|
// Notify client
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||||
|
|
||||||
@@ -469,10 +476,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val mediaSource: MediaSource
|
val mediaSource: MediaSource
|
||||||
|
|
||||||
if (playbackSession.isLocal) {
|
if (playbackSession.isLocal) {
|
||||||
Log.d(tag, "Playing Local Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing local item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||||
|
|
||||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
// @see
|
// @see
|
||||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
@@ -483,10 +492,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||||
.createMediaSource(mediaItems[0])
|
.createMediaSource(mediaItems[0])
|
||||||
} else if (!playbackSession.isHLS) {
|
} else if (!playbackSession.isHLS) {
|
||||||
Log.d(tag, "Direct Playing Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Direct playing item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
|
||||||
val extractorsFactory = DefaultExtractorsFactory()
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||||
|
|
||||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
// @see
|
// @see
|
||||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
@@ -498,7 +509,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
|
||||||
.createMediaSource(mediaItems[0])
|
.createMediaSource(mediaItems[0])
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Playing HLS Item")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
dataSourceFactory.setUserAgent(channelId)
|
dataSourceFactory.setUserAgent(channelId)
|
||||||
dataSourceFactory.setDefaultRequestProperties(
|
dataSourceFactory.setDefaultRequestProperties(
|
||||||
@@ -701,11 +712,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
mediaSessionConnector.setPlayer(castPlayer)
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
playerNotificationManager.setPlayer(castPlayer)
|
||||||
|
setMediaSessionToCastVolume()
|
||||||
castPlayer as CastPlayer
|
castPlayer as CastPlayer
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
||||||
mediaSessionConnector.setPlayer(mPlayer)
|
mediaSessionConnector.setPlayer(mPlayer)
|
||||||
playerNotificationManager.setPlayer(mPlayer)
|
playerNotificationManager.setPlayer(mPlayer)
|
||||||
|
setMediaSessionToLocalVolume()
|
||||||
mPlayer
|
mPlayer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,6 +735,33 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setMediaSessionToCastVolume() {
|
||||||
|
val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 }
|
||||||
|
val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
// Clamp, update UI immediately, then send to device
|
||||||
|
val clamped = volume.coerceIn(0, 100)
|
||||||
|
setCurrentVolume(clamped)
|
||||||
|
try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
// Use Android-provided step (−1, 0, +1). Clamp, update UI immediately, then send.
|
||||||
|
val current = currentVolume
|
||||||
|
val target = (current + direction).coerceIn(0, 100)
|
||||||
|
setCurrentVolume(target)
|
||||||
|
try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remoteVolumeProvider = provider
|
||||||
|
mediaSession.setPlaybackToRemote(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setMediaSessionToLocalVolume() {
|
||||||
|
mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||||||
|
remoteVolumeProvider = null
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentTrackStartOffsetMs(): Long {
|
fun getCurrentTrackStartOffsetMs(): Long {
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
return if (currentPlayer.mediaItemCount > 1) {
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||||
@@ -1083,6 +1123,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
private val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
private val CONTINUE_ROOT = "__CONTINUE__"
|
private val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
private lateinit var browseTree: BrowseTree
|
private lateinit var browseTree: BrowseTree
|
||||||
|
private val browseTreeInitListeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
|
private fun waitForBrowseTree(cb: () -> Unit)
|
||||||
|
{
|
||||||
|
if (this::browseTree.isInitialized)
|
||||||
|
{
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
browseTreeInitListeners += cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onBrowseTreeInitialized()
|
||||||
|
{
|
||||||
|
// Called after browseTree is assigned for the first time
|
||||||
|
browseTreeInitListeners.forEach { it.invoke() }
|
||||||
|
browseTreeInitListeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Only allowing android auto or similar to access media browser service
|
// Only allowing android auto or similar to access media browser service
|
||||||
// normal loading of audiobooks is handled in webview (not natively)
|
// normal loading of audiobooks is handled in webview (not natively)
|
||||||
@@ -1105,11 +1165,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// No further calls will be made to other media browsing methods.
|
// No further calls will be made to other media browsing methods.
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Android Auto starting $clientPackageName $clientUid")
|
AbsLogger.info(tag, "onGetRoot: clientPackageName: $clientPackageName, clientUid: $clientUid")
|
||||||
isStarted = true
|
isStarted = true
|
||||||
|
|
||||||
// Reset cache if no longer connected to server or server changed
|
// Reset cache if no longer connected to server or server changed
|
||||||
if (mediaManager.checkResetServerItems()) {
|
if (mediaManager.checkResetServerItems()) {
|
||||||
|
AbsLogger.info(tag, "onGetRoot: Reset Android Auto server items cache (${DeviceManager.serverConnectionConfigString})")
|
||||||
forceReloadingAndroidAuto = true
|
forceReloadingAndroidAuto = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,7 +1195,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
parentMediaId: String,
|
parentMediaId: String,
|
||||||
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
|
||||||
) {
|
) {
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
AbsLogger.info(tag, "onLoadChildren: parentMediaId: $parentMediaId (${DeviceManager.serverConnectionConfigString})")
|
||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
@@ -1145,7 +1206,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
@@ -1242,8 +1302,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.d(tag, "Trying to initialize browseTree.")
|
Log.d(tag, "Trying to initialize browseTree.")
|
||||||
if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) {
|
if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) {
|
||||||
forceReloadingAndroidAuto = false
|
forceReloadingAndroidAuto = false
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Loading Android Auto items")
|
||||||
mediaManager.loadAndroidAutoItems {
|
mediaManager.loadAndroidAutoItems {
|
||||||
Log.d(tag, "android auto loaded. Starting browseTree initialize")
|
AbsLogger.info(tag, "onLoadChildren: Loaded Android Auto data, initializing browseTree")
|
||||||
|
|
||||||
browseTree =
|
browseTree =
|
||||||
BrowseTree(
|
BrowseTree(
|
||||||
this,
|
this,
|
||||||
@@ -1251,6 +1313,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaManager.serverLibraries,
|
mediaManager.serverLibraries,
|
||||||
mediaManager.allLibraryPersonalizationsDone
|
mediaManager.allLibraryPersonalizationsDone
|
||||||
)
|
)
|
||||||
|
onBrowseTreeInitialized()
|
||||||
val children =
|
val children =
|
||||||
browseTree[parentMediaId]?.map { item ->
|
browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||||
@@ -1259,15 +1322,21 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d(tag, "browseTree initialize and android auto loaded")
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
firstLoadDone = true
|
firstLoadDone = true
|
||||||
if (mediaManager.serverLibraries.isNotEmpty()) {
|
if (mediaManager.serverLibraries.isNotEmpty()) {
|
||||||
Log.d(tag, "Starting personalization fetch")
|
AbsLogger.info(tag, "onLoadChildren: Android Auto fetching personalized data for all libraries")
|
||||||
mediaManager.populatePersonalizedDataForAllLibraries { notifyChildrenChanged("/") }
|
mediaManager.populatePersonalizedDataForAllLibraries {
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android Auto loaded personalized data for all libraries")
|
||||||
|
notifyChildrenChanged("/")
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(tag, "Initialize inprogress items")
|
AbsLogger.info(tag, "onLoadChildren: Android Auto fetching in progress items")
|
||||||
mediaManager.initializeInProgressItems { notifyChildrenChanged("/") }
|
mediaManager.initializeInProgressItems {
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android Auto loaded in progress items")
|
||||||
|
notifyChildrenChanged("/")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1279,6 +1348,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaManager.serverLibraries,
|
mediaManager.serverLibraries,
|
||||||
mediaManager.allLibraryPersonalizationsDone
|
mediaManager.allLibraryPersonalizationsDone
|
||||||
)
|
)
|
||||||
|
onBrowseTreeInitialized()
|
||||||
val children =
|
val children =
|
||||||
browseTree[parentMediaId]?.map { item ->
|
browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Found top menu item: ${item.description.title}")
|
Log.d(tag, "Found top menu item: ${item.description.title}")
|
||||||
@@ -1287,25 +1357,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d(tag, "browseTree initialize and android auto loaded")
|
|
||||||
|
AbsLogger.info(tag, "onLoadChildren: Android auto data loaded")
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) {
|
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT)
|
||||||
|
{
|
||||||
Log.d(tag, "First load done: $firstLoadDone")
|
Log.d(tag, "First load done: $firstLoadDone")
|
||||||
if (!firstLoadDone) {
|
if (!firstLoadDone)
|
||||||
|
{
|
||||||
result.sendResult(null)
|
result.sendResult(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Wait until top-menu is initialized
|
|
||||||
while (!this::browseTree.isInitialized) {}
|
if (!this::browseTree.isInitialized)
|
||||||
val children =
|
{
|
||||||
browseTree[parentMediaId]?.map { item ->
|
// ✅ good: detach and wait for init
|
||||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
result.detach()
|
||||||
MediaBrowserCompat.MediaItem(
|
waitForBrowseTree {
|
||||||
item.description,
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||||
)
|
MediaBrowserCompat.MediaItem(
|
||||||
}
|
item.description,
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already initialized: just return
|
||||||
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
|
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||||
|
MediaBrowserCompat.MediaItem(
|
||||||
|
item.description,
|
||||||
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||||
Log.d(tag, "Loading items for library $parentMediaId")
|
Log.d(tag, "Loading items for library $parentMediaId")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,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
|
||||||
@@ -32,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>)
|
||||||
@@ -109,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")
|
||||||
@@ -141,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")) {
|
||||||
@@ -333,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,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 {
|
||||||
@@ -467,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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,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"
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
@@ -14,7 +14,7 @@ ext {
|
|||||||
androidx_core_ktx_version = '1.16.0'
|
androidx_core_ktx_version = '1.16.0'
|
||||||
androidx_media_version = '1.7.0'
|
androidx_media_version = '1.7.0'
|
||||||
exoplayer_version = '2.18.7'
|
exoplayer_version = '2.18.7'
|
||||||
glide_version = '4.11.0'
|
glide_version = '4.16.0'
|
||||||
junit_version = '4.13.2'
|
junit_version = '4.13.2'
|
||||||
kotlin_version = '2.1.0'
|
kotlin_version = '2.1.0'
|
||||||
kotlin_coroutines_version = '1.10.1'
|
kotlin_coroutines_version = '1.10.1'
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ class WrappingMarquee {
|
|||||||
this.setMask(true)
|
this.setMask(true)
|
||||||
|
|
||||||
let textScrollAmount = this.el.scrollWidth
|
let textScrollAmount = this.el.scrollWidth
|
||||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space
|
||||||
let totalScrollAmount = this.el.scrollWidth
|
let totalScrollAmount = this.el.scrollWidth
|
||||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||||
|
|
||||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
this.pEl.textContent = this.pEl.textContent + this.innerText
|
||||||
|
|
||||||
let done = false
|
let done = false
|
||||||
let start, previousTimeStamp
|
let start, previousTimeStamp
|
||||||
@@ -55,13 +55,14 @@ class WrappingMarquee {
|
|||||||
const elapsed = timeStamp - start
|
const elapsed = timeStamp - start
|
||||||
|
|
||||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount)
|
||||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||||
if (amountToMove === totalScrollAmount) done = true
|
if (amountToMove === totalScrollAmount) done = true
|
||||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isScrolling || done) { // canceled or done
|
if (!this.isScrolling || done) {
|
||||||
|
// canceled or done
|
||||||
this.isScrolling = false
|
this.isScrolling = false
|
||||||
this.pEl.style.transform = 'translateX(0px)'
|
this.pEl.style.transform = 'translateX(0px)'
|
||||||
this.pEl.innerText = this.innerText
|
this.pEl.innerText = this.innerText
|
||||||
@@ -69,7 +70,8 @@ class WrappingMarquee {
|
|||||||
if (done) {
|
if (done) {
|
||||||
this.startTimer()
|
this.startTimer()
|
||||||
}
|
}
|
||||||
} else if (elapsed < scrollDuration) { // step
|
} else if (elapsed < scrollDuration) {
|
||||||
|
// step
|
||||||
previousTimeStamp = timeStamp
|
previousTimeStamp = timeStamp
|
||||||
this.animationId = window.requestAnimationFrame(step)
|
this.animationId = window.requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import "./tailwind.css";
|
@import './tailwind.css';
|
||||||
@import "./fonts.css";
|
@import './fonts.css';
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
@import './absicons.css';
|
@import './absicons.css';
|
||||||
@import './transitions.css';
|
@import './transitions.css';
|
||||||
@@ -61,6 +61,10 @@ textarea {
|
|||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-shadow-progressbar {
|
||||||
|
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,25 @@
|
|||||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme='black'] {
|
||||||
|
color: white;
|
||||||
|
--color-bg: 0 0 0;
|
||||||
|
--color-bg-hover: 0 0 0;
|
||||||
|
--color-fg: 230 237 243;
|
||||||
|
--color-fg-muted: 120 126 132;
|
||||||
|
--color-primary: 0 0 0;
|
||||||
|
--color-secondary: 0 0 0;
|
||||||
|
--color-border: 55 62 65;
|
||||||
|
--color-bg-toggle: 0 0 0;
|
||||||
|
--color-bg-toggle-selected: 35 35 35;
|
||||||
|
--color-track-cursor: 229 231 235;
|
||||||
|
--color-track: 107 114 128;
|
||||||
|
--color-track-buffered: 75 85 99;
|
||||||
|
--gradient-item-page: rgb(0, 0, 0);
|
||||||
|
--gradient-audio-player: rgb(0, 0, 0);
|
||||||
|
--gradient-minimized-audio-player: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme='light'] {
|
html[data-theme='light'] {
|
||||||
color: black;
|
color: black;
|
||||||
--color-bg: 255 255 255;
|
--color-bg: 255 255 255;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||||
<img src="/Logo.png" class="h-10 w-10" />
|
<img src="/Logo.png" class="h-10 w-10" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
<a v-if="showBack" @click="back" aria-label="Back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||||
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="user && currentLibrary && socketConnected">
|
<div v-if="user && currentLibrary">
|
||||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
<button type="button" aria-label="Show library modal" class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-connection-indicator />
|
<widgets-connection-indicator />
|
||||||
@@ -21,19 +21,19 @@
|
|||||||
<widgets-download-progress-indicator />
|
<widgets-download-progress-indicator />
|
||||||
|
|
||||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
<button type="button" aria-label="Cast" v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
||||||
<span class="material-symbols text-2xl leading-none">
|
<span class="material-symbols text-2xl leading-none">
|
||||||
{{ isCasting ? 'cast_connected' : 'cast' }}
|
{{ isCasting ? 'cast_connected' : 'cast' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search">
|
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search" aria-label="Search">
|
||||||
<span class="material-symbols text-2xl leading-none">search</span>
|
<span class="material-symbols text-2xl leading-none">search</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="h-7 mx-1.5">
|
<button type="button" aria-label="Toggle side drawer" class="h-7 mx-1.5" @click="clickShowSideDrawer">
|
||||||
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
<span class="material-symbols" style="font-size: 1.75rem">menu</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -56,9 +56,6 @@ export default {
|
|||||||
this.$store.commit('setCastAvailable', val)
|
this.$store.commit('setCastAvailable', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
socketConnected() {
|
|
||||||
return this.$store.state.socketConnected
|
|
||||||
},
|
|
||||||
currentLibrary() {
|
currentLibrary() {
|
||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
@@ -103,14 +100,14 @@ export default {
|
|||||||
this.isCastAvailable = data && data.value
|
this.isCastAvailable = data && data.value
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
||||||
this.isCastAvailable = data && data.value
|
this.isCastAvailable = data && data.value
|
||||||
})
|
})
|
||||||
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
|
this.onCastAvailableUpdateListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
|
||||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||||
|
<!-- Fullscreen overlays: colored background and menu overlays -->
|
||||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||||
|
<!-- Background gradient for player -->
|
||||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||||
|
|
||||||
|
<!-- Collapse button - minimizes player -->
|
||||||
<div class="top-4 left-4 absolute cursor-pointer">
|
<div class="top-4 left-4 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cast button - Chromecast toggle -->
|
||||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="coverBgIsLight ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Player options menu button -->
|
||||||
<div class="top-6 right-4 absolute cursor-pointer">
|
<div class="top-6 right-4 absolute cursor-pointer">
|
||||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
|
||||||
|
<!-- Playback method indicator (Direct/Local/Transcode) -->
|
||||||
|
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall book progress bar -->
|
||||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||||
@@ -22,7 +33,7 @@
|
|||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="h-1 w-full bg-track/50 relative rounded-full">
|
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden">
|
||||||
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
@@ -30,16 +41,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Book cover wrapper -->
|
||||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||||
<div class="w-full h-full flex justify-center">
|
<div class="w-full h-full flex justify-center">
|
||||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync failed indicator - shows error icon if progress sync failed -->
|
||||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
|
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
|
||||||
<span class="material-symbols text-error text-3xl">error</span>
|
<span class="material-symbols text-error text-3xl">error</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Title and author text -->
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||||
<p class="title-text whitespace-nowrap"></p>
|
<p class="title-text whitespace-nowrap"></p>
|
||||||
@@ -48,6 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||||
|
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters -->
|
||||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||||
<div class="flex items-center justify-between pointer-events-auto">
|
<div class="flex items-center justify-between pointer-events-auto">
|
||||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
||||||
@@ -67,28 +82,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||||
|
|
||||||
|
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
|
||||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||||
|
<span class="material-symbols text-3xl leading-none">replay</span>
|
||||||
|
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
|
||||||
|
</div>
|
||||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||||
|
|
||||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<span v-if="!showLoadingState" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
<span class="material-symbols text-3xl leading-none">forward_media</span>
|
||||||
|
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapter progress bar -->
|
||||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||||
<div class="flex pointer-events-none">
|
<div class="flex pointer-events-none">
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full overflow-hidden" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
|
||||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
@@ -99,7 +122,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapters modal - lists chapters for navigation -->
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||||
|
<!-- More menu dialog - player settings and options -->
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -108,8 +133,9 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||||
|
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -121,6 +147,7 @@ export default {
|
|||||||
sleepTimeRemaining: Number,
|
sleepTimeRemaining: Number,
|
||||||
serverLibraryItemId: String
|
serverLibraryItemId: String
|
||||||
},
|
},
|
||||||
|
mixins: [jumpLabelMixin],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
@@ -140,13 +167,6 @@ export default {
|
|||||||
readyTrackWidth: 0,
|
readyTrackWidth: 0,
|
||||||
seekedTime: 0,
|
seekedTime: 0,
|
||||||
seekLoading: false,
|
seekLoading: false,
|
||||||
onPlaybackSessionListener: null,
|
|
||||||
onPlaybackClosedListener: null,
|
|
||||||
onPlayingUpdateListener: null,
|
|
||||||
onMetadataListener: null,
|
|
||||||
onProgressSyncFailing: null,
|
|
||||||
onProgressSyncSuccess: null,
|
|
||||||
onPlaybackSpeedChangedListener: null,
|
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
playerSettings: {
|
playerSettings: {
|
||||||
@@ -156,6 +176,7 @@ export default {
|
|||||||
lockUi: false
|
lockUi: false
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isCheckingServerProgress: false,
|
||||||
isDraggingCursor: false,
|
isDraggingCursor: false,
|
||||||
draggingTouchStartX: 0,
|
draggingTouchStartX: 0,
|
||||||
draggingTouchStartTime: 0,
|
draggingTouchStartTime: 0,
|
||||||
@@ -182,6 +203,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
theme() {
|
||||||
|
return document.documentElement.dataset.theme || 'dark'
|
||||||
|
},
|
||||||
menuItems() {
|
menuItems() {
|
||||||
const items = []
|
const items = []
|
||||||
// TODO: Implement on iOS
|
// TODO: Implement on iOS
|
||||||
@@ -225,11 +249,11 @@ export default {
|
|||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
jumpForwardIcon() {
|
jumpForwardLabel() {
|
||||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
return this.getJumpLabel(this.jumpForwardTime)
|
||||||
},
|
},
|
||||||
jumpBackwardsIcon() {
|
jumpBackwardsLabel() {
|
||||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
return this.getJumpLabel(this.jumpBackwardsTime)
|
||||||
},
|
},
|
||||||
jumpForwardTime() {
|
jumpForwardTime() {
|
||||||
return this.$store.getters['getJumpForwardTime']
|
return this.$store.getters['getJumpForwardTime']
|
||||||
@@ -266,6 +290,9 @@ export default {
|
|||||||
return 190 * heightScale
|
return 190 * heightScale
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
showLoadingState() {
|
||||||
|
return this.isLoading || this.isCheckingServerProgress
|
||||||
|
},
|
||||||
showCastBtn() {
|
showCastBtn() {
|
||||||
return this.$store.state.isCastAvailable
|
return this.$store.state.isCastAvailable
|
||||||
},
|
},
|
||||||
@@ -404,17 +431,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async coverImageLoaded(fullCoverUrl) {
|
async coverImageLoaded(fullCoverUrl) {
|
||||||
if (!fullCoverUrl) return
|
if (!fullCoverUrl) return
|
||||||
|
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||||
const fac = new FastAverageColor()
|
if (!avg) {
|
||||||
fac
|
this.coverRgb = 'rgb(55, 56, 56)'
|
||||||
.getColorAsync(fullCoverUrl)
|
this.coverBgIsLight = false
|
||||||
.then((color) => {
|
} else {
|
||||||
this.coverRgb = color.rgba
|
this.coverRgb = avg.rgba
|
||||||
this.coverBgIsLight = color.isLight
|
this.coverBgIsLight = avg.isLight
|
||||||
})
|
}
|
||||||
.catch((e) => {
|
|
||||||
console.log(e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
clickTitleAndAuthor() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
@@ -444,9 +468,8 @@ export default {
|
|||||||
this.showFullscreen = true
|
this.showFullscreen = true
|
||||||
if (this.titleMarquee) this.titleMarquee.reset()
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
// Update track for total time bar if useChapterTrack is set
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.updateTrack()
|
this.measureAndUpdateTrackWidth()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
collapseFullscreen() {
|
collapseFullscreen() {
|
||||||
@@ -457,13 +480,13 @@ export default {
|
|||||||
},
|
},
|
||||||
async jumpNextChapter() {
|
async jumpNextChapter() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (!this.nextChapter) return
|
if (!this.nextChapter) return
|
||||||
this.seek(this.nextChapter.start)
|
this.seek(this.nextChapter.start)
|
||||||
},
|
},
|
||||||
async jumpChapterStart() {
|
async jumpChapterStart() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (!this.currentChapter) {
|
if (!this.currentChapter) {
|
||||||
return this.restart()
|
return this.restart()
|
||||||
}
|
}
|
||||||
@@ -493,12 +516,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async jumpBackwards() {
|
async jumpBackwards() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||||
},
|
},
|
||||||
async jumpForward() {
|
async jumpForward() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||||
},
|
},
|
||||||
setStreamReady() {
|
setStreamReady() {
|
||||||
@@ -526,13 +549,17 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
|
if (!this.$refs.readyTrack) return
|
||||||
|
|
||||||
if (this.playerSettings.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalReadyTrack) {
|
if (this.$refs.totalReadyTrack && this.trackWidth) {
|
||||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||||
|
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
|
||||||
}
|
}
|
||||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
this.$refs.readyTrack.style.width = '100%'
|
||||||
} else {
|
} else if (this.trackWidth) {
|
||||||
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px'
|
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||||
|
this.$refs.readyTrack.style.width = readyPercent + '%'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTimestamp() {
|
updateTimestamp() {
|
||||||
@@ -584,21 +611,27 @@ export default {
|
|||||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
const playedPercent = percentDone * 100
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
const bufferedPercentWidth = bufferedPercent * 100
|
||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.playedTrack) {
|
||||||
|
this.$refs.playedTrack.style.width = playedPercent + '%'
|
||||||
|
}
|
||||||
|
if (this.$refs.bufferedTrack) {
|
||||||
|
this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
|
||||||
|
}
|
||||||
|
|
||||||
if (this.$refs.trackCursor) {
|
if (this.$refs.trackCursor && this.$refs.track) {
|
||||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
const trackWidth = this.$refs.track.clientWidth
|
||||||
|
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.playerSettings.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
|
||||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
seek(time) {
|
seek(time) {
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
if (this.seekLoading) {
|
if (this.seekLoading) {
|
||||||
console.error('Already seek loading', this.seekedTime)
|
console.error('Already seek loading', this.seekedTime)
|
||||||
return
|
return
|
||||||
@@ -607,12 +640,12 @@ export default {
|
|||||||
this.seekedTime = time
|
this.seekedTime = time
|
||||||
this.seekLoading = true
|
this.seekLoading = true
|
||||||
|
|
||||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
|
||||||
|
AbsAudioPlayer.seek({ value: time })
|
||||||
|
|
||||||
if (this.$refs.playedTrack) {
|
if (this.$refs.playedTrack) {
|
||||||
const perc = time / this.totalDuration
|
const perc = time / this.totalDuration
|
||||||
const ptWidth = Math.round(perc * this.trackWidth)
|
this.$refs.playedTrack.style.width = perc * 100 + '%'
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
|
||||||
|
|
||||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||||
@@ -630,11 +663,14 @@ export default {
|
|||||||
},
|
},
|
||||||
async playPauseClick() {
|
async playPauseClick() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.showLoadingState) return
|
||||||
|
|
||||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
},
|
},
|
||||||
|
setIsCheckingServerProgress(value) {
|
||||||
|
this.isCheckingServerProgress = !!value
|
||||||
|
},
|
||||||
play() {
|
play() {
|
||||||
AbsAudioPlayer.playPlayer()
|
AbsAudioPlayer.playPlayer()
|
||||||
this.startPlayInterval()
|
this.startPlayInterval()
|
||||||
@@ -718,7 +754,8 @@ export default {
|
|||||||
maxTime = minTime + duration
|
maxTime = minTime + duration
|
||||||
}
|
}
|
||||||
|
|
||||||
const timePerPixel = duration / this.trackWidth
|
const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
|
||||||
|
const timePerPixel = duration / trackWidth
|
||||||
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||||
|
|
||||||
@@ -856,11 +893,7 @@ export default {
|
|||||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||||
this.titleMarquee.init(this.title)
|
this.titleMarquee.init(this.title)
|
||||||
|
|
||||||
if (this.$refs.track) {
|
this.measureAndUpdateTrackWidth()
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
|
||||||
} else {
|
|
||||||
console.error('Track not loaded', this.$refs)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onPlaybackClosed() {
|
onPlaybackClosed() {
|
||||||
@@ -880,14 +913,14 @@ export default {
|
|||||||
async init() {
|
async init() {
|
||||||
await this.loadPlayerSettings()
|
await this.loadPlayerSettings()
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||||
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||||
},
|
},
|
||||||
async screenOrientationChange() {
|
async screenOrientationChange() {
|
||||||
if (this.isRefreshingUI) return
|
if (this.isRefreshingUI) return
|
||||||
@@ -906,15 +939,26 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// playerContainer width transition is 150ms, remeasure after layout settles
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
this.refreshUI()
|
||||||
|
|
||||||
this.isRefreshingUI = false
|
this.isRefreshingUI = false
|
||||||
},
|
},
|
||||||
|
measureAndUpdateTrackWidth() {
|
||||||
|
if (!this.$refs.track) {
|
||||||
|
console.error('Track not loaded', this.$refs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
this.updateTrack()
|
||||||
|
this.updateReadyTrack()
|
||||||
|
},
|
||||||
refreshUI() {
|
refreshUI() {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
if (this.$refs.track) {
|
this.$nextTick(() => {
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.measureAndUpdateTrackWidth()
|
||||||
this.updateTrack()
|
})
|
||||||
this.updateReadyTrack()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
updateScreenSize() {
|
updateScreenSize() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -981,14 +1025,9 @@ export default {
|
|||||||
document.body.removeEventListener('touchend', this.touchend)
|
document.body.removeEventListener('touchend', this.touchend)
|
||||||
document.body.removeEventListener('touchmove', this.touchmove)
|
document.body.removeEventListener('touchmove', this.touchmove)
|
||||||
|
|
||||||
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
|
if (AbsAudioPlayer.removeAllListeners) {
|
||||||
if (this.onMetadataListener) this.onMetadataListener.remove()
|
AbsAudioPlayer.removeAllListeners()
|
||||||
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
}
|
||||||
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
|
||||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
|
||||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
|
||||||
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
|
||||||
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
|
|
||||||
clearInterval(this.playInterval)
|
clearInterval(this.playInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1049,12 @@ export default {
|
|||||||
.fullscreen .playerContainer {
|
.fullscreen .playerContainer {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .playerContainer {
|
||||||
|
width: 50%;
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
#playerContent {
|
#playerContent {
|
||||||
box-shadow: 0px -8px 8px #11111155;
|
box-shadow: 0px -8px 8px #11111155;
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1066,7 @@ export default {
|
|||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: margin;
|
transition-property: margin;
|
||||||
bottom: 35px;
|
bottom: 35px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.fullscreen #playerTrack {
|
.fullscreen #playerTrack {
|
||||||
bottom: unset;
|
bottom: unset;
|
||||||
@@ -1037,13 +1083,45 @@ export default {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .cover-wrapper {
|
||||||
|
left: 25px !important;
|
||||||
|
top: 50px;
|
||||||
|
width: calc(50% - 25px) !important;
|
||||||
|
height: calc(100% - 85px) !important;
|
||||||
|
}
|
||||||
|
.fullscreen .cover-wrapper > div > div {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: 100% !important;
|
||||||
|
}
|
||||||
|
.fullscreen .cover-wrapper > div > div > div {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.fullscreen .cover-wrapper img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.total-track {
|
.total-track {
|
||||||
bottom: 215px;
|
bottom: 215px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .total-track {
|
||||||
|
left: 50%;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .title-author-texts {
|
||||||
|
left: 50% !important;
|
||||||
|
width: 50% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
.title-author-texts {
|
.title-author-texts {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: left, bottom, width, height;
|
transition-property: left, bottom, width, height;
|
||||||
@@ -1060,12 +1138,22 @@ export default {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .title-author-texts .title-text {
|
||||||
|
font-size: larger !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
.title-author-texts .author-text {
|
.title-author-texts .author-text {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: font-size;
|
transition-property: font-size;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.fullscreen .title-author-texts .author-text {
|
||||||
|
font-size: inherit !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fullscreen .title-author-texts {
|
.fullscreen .title-author-texts {
|
||||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||||
@@ -1096,6 +1184,9 @@ export default {
|
|||||||
margin: 0px 0px;
|
margin: 0px 0px;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
#playerControls .jump-label {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
#playerControls .play-btn {
|
#playerControls .play-btn {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: padding, margin, height, width, min-width, min-height;
|
transition-property: padding, margin, height, width, min-width, min-height;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer, AbsLogger } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
@@ -48,6 +48,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isIos() {
|
isIos() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
|
currentPlaybackSession() {
|
||||||
|
return this.$store.state.currentPlaybackSession
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -190,6 +193,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async playLibraryItem(payload) {
|
async playLibraryItem(payload) {
|
||||||
|
await AbsLogger.info({ tag: 'AudioPlayerContainer', message: `playLibraryItem: Received play request for library item ${payload.libraryItemId} ${payload.episodeId ? `episode ${payload.episodeId}` : ''}` })
|
||||||
const libraryItemId = payload.libraryItemId
|
const libraryItemId = payload.libraryItemId
|
||||||
const episodeId = payload.episodeId
|
const episodeId = payload.episodeId
|
||||||
const startTime = payload.startTime
|
const startTime = payload.startTime
|
||||||
@@ -225,7 +229,7 @@ export default {
|
|||||||
console.log('Already streaming item', startTime)
|
console.log('Already streaming item', startTime)
|
||||||
if (startTime !== undefined && startTime !== null) {
|
if (startTime !== undefined && startTime !== null) {
|
||||||
// seek to start time
|
// seek to start time
|
||||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
AbsAudioPlayer.seek({ value: startTime })
|
||||||
} else if (this.$refs.audioPlayer) {
|
} else if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.play()
|
this.$refs.audioPlayer.play()
|
||||||
}
|
}
|
||||||
@@ -303,58 +307,139 @@ export default {
|
|||||||
this.$refs.audioPlayer?.seek(currentTime)
|
this.$refs.audioPlayer?.seek(currentTime)
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* When device gains focus then refresh the timestamps in the audio player
|
* Fetch the current user's media progress from the server for a given library item / episode.
|
||||||
|
* Returns the server media progress object, or null if the request fails, times out, or the
|
||||||
|
* response doesn't match the requested library item.
|
||||||
|
*
|
||||||
|
* The audio player's loading state is shown while the request is in flight so the user
|
||||||
|
* doesn't tap play before we have a chance to update the timestamps. The request timeout
|
||||||
|
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
|
||||||
*/
|
*/
|
||||||
deviceFocused(hasFocus) {
|
async getServerMediaProgressForCurrentSession() {
|
||||||
if (!this.$store.state.currentPlaybackSession) return
|
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
|
||||||
|
const libraryItemId = this.currentPlaybackSession?.libraryItemId
|
||||||
|
const episodeId = this.currentPlaybackSession?.episodeId
|
||||||
|
if (!libraryItemId) return null
|
||||||
|
|
||||||
if (hasFocus) {
|
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||||
|
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||||
|
|
||||||
|
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||||
|
try {
|
||||||
|
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||||
|
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getLocalMediaProgressForCurrentSession() {
|
||||||
|
if (!this.currentPlaybackSession) return null
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Sync the server media progress with the local media progress
|
||||||
|
*/
|
||||||
|
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||||
|
try {
|
||||||
|
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||||
|
localMediaProgressId,
|
||||||
|
mediaProgress: serverMediaProgress
|
||||||
|
})
|
||||||
|
if (newLocalMediaProgress?.id) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||||
|
*/
|
||||||
|
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||||
|
if (!localMediaProgress) return
|
||||||
|
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||||
|
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||||
|
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||||
|
|
||||||
|
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||||
|
// Use seek() so the native audio player's current session is updated
|
||||||
|
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||||
|
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||||
|
*
|
||||||
|
* If there is no socket connection we may have missed external progress updates
|
||||||
|
*/
|
||||||
|
async socketReconnected() {
|
||||||
|
if (!this.currentPlaybackSession) return
|
||||||
|
// dont update timestamps if player is playing
|
||||||
|
if (this.$refs.audioPlayer?.isPlaying) return
|
||||||
|
|
||||||
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
|
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||||
|
if (!localMediaProgress) {
|
||||||
|
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* When device re-gains focus then refresh the timestamps in the audio player
|
||||||
|
* if local item is open then fetch the server media progress and update if more recent
|
||||||
|
*/
|
||||||
|
async deviceFocused(hasFocus) {
|
||||||
|
if (!this.currentPlaybackSession || !hasFocus) return
|
||||||
|
// dont update timestamps if player is playing
|
||||||
|
if (this.$refs.audioPlayer?.isPlaying) return
|
||||||
|
|
||||||
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
|
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||||
|
if (!localMediaProgress) {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||||
|
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||||
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
|
||||||
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
|
} else {
|
||||||
|
// server item so fetch server media progress and update player time
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
|
||||||
|
const data = await this.getServerMediaProgressForCurrentSession()
|
||||||
|
if (!data) return
|
||||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||||
const playbackSession = this.$store.state.currentPlaybackSession
|
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
// Only seek if the difference is greater than 1 second
|
||||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||||
const localEpisodeId = playbackSession.localEpisodeId
|
// Use seek() so the native audio player's current session is updated
|
||||||
if (!localLibraryItemId) {
|
this.$refs.audioPlayer.seek(data.currentTime)
|
||||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
|
||||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
|
||||||
return mp.localLibraryItemId === localLibraryItemId
|
|
||||||
})
|
|
||||||
if (localMediaProgress) {
|
|
||||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
|
||||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
|
||||||
this.$refs.audioPlayer.timeupdate()
|
|
||||||
} else {
|
|
||||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const libraryItemId = playbackSession.libraryItemId
|
|
||||||
const episodeId = playbackSession.episodeId
|
|
||||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
|
||||||
this.$nativeHttp
|
|
||||||
.get(url)
|
|
||||||
.then((data) => {
|
|
||||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
|
||||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
|
||||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
|
||||||
this.$refs.audioPlayer.timeupdate()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
||||||
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||||
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||||
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||||
|
|
||||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
@@ -367,12 +452,13 @@ export default {
|
|||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||||
|
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
this.onLocalMediaProgressUpdateListener?.remove()
|
||||||
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
this.onSleepTimerEndedListener?.remove()
|
||||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
this.onSleepTimerSetListener?.remove()
|
||||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
this.onMediaPlayerChangedListener?.remove()
|
||||||
|
|
||||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
@@ -382,6 +468,7 @@ export default {
|
|||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||||
|
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,19 +3,16 @@
|
|||||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||||
<div class="px-6 mb-4">
|
<div class="px-6 mb-4">
|
||||||
<p v-if="user" class="text-base">
|
<p v-if="user" class="text-base" v-html="$getString('HeaderWelcome', [username])" />
|
||||||
Welcome,
|
|
||||||
<strong>{{ username }}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full overflow-y-auto">
|
<div class="w-full overflow-y-auto">
|
||||||
<template v-for="item in navItems">
|
<template v-for="item in navItems">
|
||||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
<button v-if="item.action" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
||||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</button>
|
</button>
|
||||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
<nuxt-link v-else :to="item.to" :key="item.text" :tabindex="show ? 0 : -1" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
||||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -134,6 +131,13 @@ export default {
|
|||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
icon: 'bug_report',
|
||||||
|
iconOutlined: true,
|
||||||
|
text: this.$strings.ButtonLogs,
|
||||||
|
to: '/logs'
|
||||||
|
})
|
||||||
|
|
||||||
if (this.serverConnectionConfig) {
|
if (this.serverConnectionConfig) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'language',
|
icon: 'language',
|
||||||
@@ -170,31 +174,24 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
if (this.user) {
|
await this.$store.dispatch('user/logout')
|
||||||
if (this.$store.getters['getIsPlayerOpen']) {
|
|
||||||
this.$eventBus.$emit('close-stream')
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
|
||||||
console.error('Failed to logout', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$socket.logout()
|
|
||||||
await this.$db.logout()
|
|
||||||
this.$localStore.removeLastLibraryId()
|
|
||||||
this.$store.commit('user/logout')
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
|
||||||
},
|
},
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
await this.logout()
|
await this.logout()
|
||||||
|
|
||||||
|
// Redirect to home page
|
||||||
if (this.$route.name !== 'bookshelf') {
|
if (this.$route.name !== 'bookshelf') {
|
||||||
this.$router.replace('/bookshelf')
|
this.$router.replace('/bookshelf')
|
||||||
} else {
|
|
||||||
location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If player is open and not playing locally, then close the player
|
||||||
|
if (this.$store.getters['getIsPlayerOpen']) {
|
||||||
|
this.$eventBus.$emit('close-stream')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close side drawer
|
||||||
|
this.show = false
|
||||||
},
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
this.touchEvent = new TouchEvent(e)
|
this.touchEvent = new TouchEvent(e)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
|
<div class="grow px-2 audiobookSearchCardContent">
|
||||||
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
coverWidth() {
|
||||||
|
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||||
|
return 50
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.episode?.title || 'No Title'
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
authorName() {
|
||||||
|
return this.mediaMetadata.author
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.audiobookSearchCardContent {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full px-1 overflow-hidden">
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
<div class="grow px-2 audiobookSearchCardContent">
|
||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
|
|
||||||
|
|
||||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
|
||||||
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
|
|
||||||
|
|
||||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,9 +16,7 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
},
|
||||||
search: String,
|
search: String
|
||||||
matchKey: String,
|
|
||||||
matchText: String
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -58,35 +50,6 @@ export default {
|
|||||||
authorName() {
|
authorName() {
|
||||||
if (this.isPodcast) return this.mediaMetadata.author
|
if (this.isPodcast) return this.mediaMetadata.author
|
||||||
return this.mediaMetadata.authorName
|
return this.mediaMetadata.authorName
|
||||||
},
|
|
||||||
matchHtml() {
|
|
||||||
if (!this.matchText || !this.search) return ''
|
|
||||||
if (this.matchKey === 'subtitle') return ''
|
|
||||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
|
||||||
if (matchSplit.length < 2) return ''
|
|
||||||
|
|
||||||
var html = ''
|
|
||||||
var totalLenSoFar = 0
|
|
||||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
|
||||||
var indexOf = matchSplit[i].length
|
|
||||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
|
||||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
|
||||||
totalLenSoFar += indexOf + this.search.length
|
|
||||||
|
|
||||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
|
||||||
}
|
|
||||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
|
||||||
html += lastPart
|
|
||||||
|
|
||||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
|
||||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
|
||||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
|
||||||
if (this.matchKey === 'authors') return `by ${html}`
|
|
||||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
|
||||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
|
||||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
|
||||||
return `${html}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
<div ref="card" tabindex="0" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
||||||
<!-- When cover image does not fill -->
|
<!-- When cover image does not fill -->
|
||||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- No progress shown for collapsed series in library -->
|
||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Downloaded icon -->
|
<!-- Downloaded icon -->
|
||||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black/90 text-white box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- Podcast Episode # -->
|
||||||
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
<!-- No progress shown for collapsed series or podcasts in library -->
|
||||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow pl-2" :class="showPlayButton ? 'pr-12' : 'pr-2'">
|
||||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
@@ -29,6 +29,16 @@
|
|||||||
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showPlayButton" class="absolute top-0 bottom-0 right-0 h-full flex items-center justify-center z-20 pr-1">
|
||||||
|
<button type="button" class="relative rounded-full bg-fg-muted/50" :class="{ 'p-2': !playerIsStartingForThisMedia }" @click.stop.prevent="play">
|
||||||
|
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill text-white">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
<div v-else class="p-2 text-fg w-10 h-10 flex items-center justify-center bg-fg-muted/80 rounded-full overflow-hidden">
|
||||||
|
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-symbols text-2xl text-success">download_done</span>
|
<span class="material-symbols text-2xl text-success">download_done</span>
|
||||||
@@ -129,6 +139,9 @@ export default {
|
|||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this._libraryItem.id
|
return this._libraryItem.id
|
||||||
},
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id
|
||||||
|
},
|
||||||
series() {
|
series() {
|
||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
@@ -217,13 +230,31 @@ export default {
|
|||||||
return this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.isPlaying && !this.store.getters['getIsCurrentSessionLocal']
|
||||||
|
},
|
||||||
|
isPlaying() {
|
||||||
|
if (this.localLibraryItemId && this.store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
|
||||||
|
return this.store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||||
|
},
|
||||||
|
playerIsPlaying() {
|
||||||
|
return this.store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||||
|
},
|
||||||
|
playerIsStartingPlayback() {
|
||||||
|
// Play has been pressed and waiting for native play response
|
||||||
|
return this.store.state.playerIsStartingPlayback
|
||||||
|
},
|
||||||
|
playerIsStartingForThisMedia() {
|
||||||
|
const mediaId = this.store.state.playerStartingPlaybackMediaId
|
||||||
|
return mediaId === this.libraryItemId
|
||||||
|
},
|
||||||
|
isCasting() {
|
||||||
|
return this.store.state.isCasting
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isPodcast
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook
|
||||||
@@ -293,9 +324,32 @@ export default {
|
|||||||
this.selected = !this.selected
|
this.selected = !this.selected
|
||||||
this.$emit('select', this.libraryItem)
|
this.$emit('select', this.libraryItem)
|
||||||
},
|
},
|
||||||
play() {
|
async play() {
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
if (this.playerIsStartingPlayback) return
|
||||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
|
||||||
|
const hapticsImpact = this.$hapticsImpact || this.$nuxt.$hapticsImpact
|
||||||
|
if (hapticsImpact) {
|
||||||
|
await hapticsImpact()
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
|
|
||||||
|
if (this.playerIsPlaying) {
|
||||||
|
eventBus.$emit('pause-item')
|
||||||
|
} else {
|
||||||
|
// Audiobook
|
||||||
|
let libraryItemId = this.libraryItemId
|
||||||
|
|
||||||
|
// When casting use server library item
|
||||||
|
if (this.localLibraryItem && !this.isCasting) {
|
||||||
|
libraryItemId = this.localLibraryItem.id
|
||||||
|
} else if (this.hasLocal) {
|
||||||
|
libraryItemId = this.localLibraryItem.id
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
|
||||||
|
eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.libraryItemId })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
// destroy the vue listeners, etc
|
// destroy the vue listeners, etc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
||||||
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
|||||||
@@ -4,19 +4,27 @@
|
|||||||
<!-- list of server connection configs -->
|
<!-- list of server connection configs -->
|
||||||
<template v-if="!showForm">
|
<template v-if="!showForm">
|
||||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
<div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
|
||||||
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
<div class="grow inline-flex items-center overflow-hidden">
|
||||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
<p class="text-base text-fg truncate">{{ config.name }}</p>
|
||||||
|
</div>
|
||||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
<div class="h-full w-6 flex items-center" @click.stop="editServerConfig(config)">
|
||||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="h-full w-6 flex items-center" @click.stop="removeServerConfigClick(config)">
|
||||||
|
<span class="material-symbols fill text-1.5xl text-fg-muted">delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- warning message if server connection config is using an old user id -->
|
<!-- warning message if server connection config is using an old user id -->
|
||||||
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||||
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
||||||
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- warning message if server connection config is using an old auth method -->
|
||||||
|
<div v-if="config.version && checkIsUsingOldAuth(config)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||||
|
<p class="text-xs text-warning">{{ $strings.MessageOldServerAuthWarning }}</p>
|
||||||
|
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldAuthWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-1 py-4 w-full">
|
<div class="my-1 py-4 w-full">
|
||||||
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
||||||
@@ -25,7 +33,7 @@
|
|||||||
<!-- form to add a new server connection config -->
|
<!-- form to add a new server connection config -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<!-- server address input -->
|
<!-- server address input -->
|
||||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
<form v-if="!showAuth" @submit.prevent="submit(false)" novalidate class="w-full">
|
||||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +60,7 @@
|
|||||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
<ui-icon-btn v-if="serverConfig.id" bg-color="error" icon="delete" type="button" @click="removeServerConfigClick(serverConfig)" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,9 +79,6 @@
|
|||||||
|
|
||||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||||
<div>
|
<div>
|
||||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
|
||||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
|
||||||
</div>
|
|
||||||
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -155,9 +160,23 @@ export default {
|
|||||||
cancelText: this.$strings.ButtonOk
|
cancelText: this.$strings.ButtonOk
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
async showOldAuthWarningDialog() {
|
||||||
|
const confirmResult = await Dialog.confirm({
|
||||||
|
title: 'Old Server Auth Warning',
|
||||||
|
message: this.$strings.MessageOldServerAuthWarningHelp,
|
||||||
|
cancelButtonTitle: this.$strings.ButtonReadMore
|
||||||
|
})
|
||||||
|
if (!confirmResult.value) {
|
||||||
|
window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank')
|
||||||
|
}
|
||||||
|
},
|
||||||
checkIdUuid(userId) {
|
checkIdUuid(userId) {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
||||||
},
|
},
|
||||||
|
checkIsUsingOldAuth(config) {
|
||||||
|
if (!config.version) return true
|
||||||
|
return !this.$isValidVersion(config.version, '2.26.0')
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Initiates the login process using OpenID via OAuth2.0.
|
* Initiates the login process using OpenID via OAuth2.0.
|
||||||
* 1. Verifying the server's address
|
* 1. Verifying the server's address
|
||||||
@@ -374,26 +393,36 @@ export default {
|
|||||||
} catch (error) {} // No Error handling needed
|
} catch (error) {} // No Error handling needed
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Returns the same user response payload as /login
|
||||||
const response = await CapacitorHttp.get({
|
const response = await CapacitorHttp.get({
|
||||||
url: backendEndpoint
|
url: backendEndpoint
|
||||||
})
|
})
|
||||||
|
// v2.26.0+ returns accessToken and refreshToken on user object
|
||||||
if (!response.data || !response.data.user || !response.data.user.token) {
|
if (!response.data?.user?.token && !response.data?.user?.accessToken) {
|
||||||
throw new Error('Token data is missing in the response.')
|
throw new Error('Token is missing in response.')
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.token = response.data.user.token
|
const user = response.data.user
|
||||||
|
this.serverConfig.token = user.accessToken || user.token
|
||||||
|
|
||||||
|
// TODO: Is it necessary to authenticate again?
|
||||||
const payload = await this.authenticateToken()
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
throw new Error('Authentication failed with the provided token.')
|
throw new Error('Authentication failed with the provided token.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username && scc.id !== this.serverConfig.id)
|
||||||
if (duplicateConfig) {
|
if (duplicateConfig) {
|
||||||
throw new Error('Config already exists for this address and username.')
|
throw new Error('Config already exists for this address and username.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them
|
||||||
|
if (user.accessToken) {
|
||||||
|
payload.user.accessToken = user.accessToken
|
||||||
|
payload.user.refreshToken = user.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||||
@@ -436,27 +465,32 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.error = null
|
this.error = null
|
||||||
var payload = await this.authenticateToken()
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
// Will NOT include access token and refresh token
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
} else {
|
} else {
|
||||||
this.showAuth = true
|
let error = this.error
|
||||||
|
if (await this.submit(true)) {
|
||||||
|
this.showForm = true
|
||||||
|
this.error = error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async removeServerConfigClick() {
|
async removeServerConfigClick(serverConfig) {
|
||||||
if (!this.serverConfig.id) return
|
if (!serverConfig.id) return
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Remove this server config?`
|
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
await this.$db.removeServerConnectionConfig(serverConfig.id)
|
||||||
const updatedDeviceData = { ...this.deviceData }
|
const updatedDeviceData = { ...this.deviceData }
|
||||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != serverConfig.id)
|
||||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||||
|
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
@@ -468,14 +502,17 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
this.showAuth = false
|
this.showAuth = false
|
||||||
this.showForm = !this.serverConnectionConfigs.length
|
this.showForm = !this.serverConnectionConfigs.length
|
||||||
|
this.error = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editServerConfig(serverConfig) {
|
async editServerConfig(serverConfig) {
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
...serverConfig
|
...serverConfig
|
||||||
}
|
}
|
||||||
this.showForm = true
|
|
||||||
this.showAuth = true
|
if (await this.submit(true)) {
|
||||||
|
this.showForm = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async newServerConfigClick() {
|
async newServerConfigClick() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
@@ -504,7 +541,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
var urlObject = new URL(url)
|
var urlObject = new URL(url)
|
||||||
if (protocolOverride) urlObject.protocol = protocolOverride
|
if (protocolOverride) urlObject.protocol = protocolOverride
|
||||||
return urlObject.href
|
return urlObject.href.replace(/\/$/, '') // Remove trailing slash
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Invalid URL', error)
|
console.error('Invalid URL', error)
|
||||||
return null
|
return null
|
||||||
@@ -597,7 +634,12 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
requestServerLogin() {
|
requestServerLogin() {
|
||||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
const headers = {
|
||||||
|
// Tells the Abs server to return the refresh token
|
||||||
|
'x-return-tokens': 'true',
|
||||||
|
...(this.serverConfig.customHeaders || {})
|
||||||
|
}
|
||||||
|
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
@@ -616,9 +658,8 @@ export default {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async submit() {
|
async submit(preventAutoLogin = false) {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected || !this.serverConfig.address) return false
|
||||||
if (!this.serverConfig.address) return
|
|
||||||
|
|
||||||
const initialAddress = this.serverConfig.address
|
const initialAddress = this.serverConfig.address
|
||||||
// Did the user specify a protocol?
|
// Did the user specify a protocol?
|
||||||
@@ -631,6 +672,7 @@ export default {
|
|||||||
this.authMethods = []
|
this.authMethods = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[ServerConnectForm] submit tryServerUrl: ' + this.serverConfig.address)
|
||||||
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||||
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||||
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||||
@@ -639,12 +681,17 @@ export default {
|
|||||||
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||||
this.serverConfig.version = statusData.data.serverVersion
|
this.serverConfig.version = statusData.data.serverVersion
|
||||||
|
|
||||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
if (statusData.data.authFormData?.authOpenIDAutoLaunch && !preventAutoLogin) {
|
||||||
this.clickLoginWithOpenId()
|
this.clickLoginWithOpenId()
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
console.log('[ServerConnectForm] submit validateLoginFormResponse failed: ' + this.serverConfig.address)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleLoginFormError(error)
|
this.handleLoginFormError(error)
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
}
|
}
|
||||||
@@ -770,26 +817,6 @@ export default {
|
|||||||
prependProtocolIfNeeded(address) {
|
prependProtocolIfNeeded(address) {
|
||||||
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Compares two semantic versioning strings to determine if the current version meets
|
|
||||||
* or exceeds the minimum version requirement.
|
|
||||||
*
|
|
||||||
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
|
||||||
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
|
||||||
* @returns {boolean} - Returns true if the current version is greater than or equal
|
|
||||||
* to the minimum version, false otherwise.
|
|
||||||
*/
|
|
||||||
isValidVersion(currentVersion, minVersion) {
|
|
||||||
const currentParts = currentVersion.split('.').map(Number)
|
|
||||||
const minParts = minVersion.split('.').map(Number)
|
|
||||||
|
|
||||||
for (let i = 0; i < minParts.length; i++) {
|
|
||||||
if (currentParts[i] > minParts[i]) return true
|
|
||||||
if (currentParts[i] < minParts[i]) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
async submitAuth() {
|
async submitAuth() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.username) {
|
if (!this.serverConfig.username) {
|
||||||
@@ -806,33 +833,46 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var payload = await this.requestServerLogin()
|
const payload = await this.requestServerLogin()
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (payload) {
|
if (payload) {
|
||||||
|
// Will include access token and refresh token
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
console.log('Successfully logged in: ' + user.username)
|
||||||
|
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||||
this.$setServerLanguageCode(serverSettings.language)
|
this.$setServerLanguageCode(serverSettings.language)
|
||||||
|
|
||||||
// Set library - Use last library if set and available fallback to default user library
|
this.serverConfig.userId = user.id
|
||||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
this.serverConfig.username = user.username
|
||||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
|
||||||
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
|
||||||
} else if (userDefaultLibraryId) {
|
// Tokens only returned from /login endpoint
|
||||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
if (user.accessToken) {
|
||||||
|
this.serverConfig.token = user.accessToken
|
||||||
|
this.serverConfig.refreshToken = user.refreshToken
|
||||||
|
} else {
|
||||||
|
// Detect if the connection config is using the old token. If so, force re-login
|
||||||
|
if (this.serverConfig.token === user.token || user.isOldToken) {
|
||||||
|
this.setForceRelogin('oldAuthToken')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch
|
||||||
|
this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server version before new JWT auth, use old user.token
|
||||||
|
this.serverConfig.token = user.token
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.userId = user.id
|
this.serverConfig.version = serverSettings.version
|
||||||
this.serverConfig.token = user.token
|
|
||||||
this.serverConfig.username = user.username
|
|
||||||
delete this.serverConfig.version
|
|
||||||
|
|
||||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||||
|
|
||||||
@@ -849,7 +889,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
|
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
|
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
||||||
|
} else if (userDefaultLibraryId) {
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||||
|
}
|
||||||
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
||||||
@@ -865,7 +914,13 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
const nativeHttpOptions = {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.serverConfig.token}`
|
||||||
|
},
|
||||||
|
serverConnectionConfig: this.serverConfig
|
||||||
|
}
|
||||||
|
const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
|
||||||
console.error('[ServerConnectForm] Server auth failed', error)
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
const errorMsg = error.message || error
|
const errorMsg = error.message || error
|
||||||
this.error = 'Failed to authorize'
|
this.error = 'Failed to authorize'
|
||||||
@@ -874,14 +929,40 @@ export default {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[ServerConnectForm] authRes=', authRes)
|
console.log('[ServerConnectForm] authRes=', authRes)
|
||||||
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
|
async setForceRelogin(error) {
|
||||||
|
console.log('[ServerConnectForm] setForceRelogin', error, this.serverConfig.address)
|
||||||
|
// This calls /status on the server and sets the auth methods
|
||||||
|
const result = await this.submit(true)
|
||||||
|
if (result) {
|
||||||
|
this.showForm = true
|
||||||
|
|
||||||
|
if (error === 'oldAuthToken') {
|
||||||
|
this.error = this.$strings.MessageOldServerAuthReLoginRequired
|
||||||
|
} else if (error === 'refreshTokenFailed') {
|
||||||
|
this.error = this.$strings.MessageFailedToRefreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
init() {
|
init() {
|
||||||
|
if (this.$route.query.serverConnectionConfigId) {
|
||||||
|
// Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed
|
||||||
|
const _serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||||
|
if (_serverConfig) {
|
||||||
|
this.serverConfig = { ..._serverConfig }
|
||||||
|
this.setForceRelogin(this.$route.query.error)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
console.error('[ServerConnectForm] init with serverConnectionConfigId but no serverConfig found', this.$route.query.serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
|
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
this.showForm = !this.serverConnectionConfigs.length
|
this.showForm = !this.serverConnectionConfigs.length
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newBookmarkTitle" ref="noteInput" label="Note" />
|
<ui-text-input-with-label v-model="newBookmarkTitle" :placeholder="bookmarkPlaceholder()" :autofocus="false" ref="noteInput" label="Note" />
|
||||||
<div class="flex justify-end mt-6">
|
<div class="flex justify-end mt-6">
|
||||||
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
|
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,6 +93,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
bookmarkPlaceholder() {
|
||||||
|
// using a method prevents caching the date
|
||||||
|
return this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
|
},
|
||||||
editBookmark(bm) {
|
editBookmark(bm) {
|
||||||
this.selectedBookmark = bm
|
this.selectedBookmark = bm
|
||||||
this.newBookmarkTitle = bm.title
|
this.newBookmarkTitle = bm.title
|
||||||
@@ -157,18 +161,8 @@ export default {
|
|||||||
},
|
},
|
||||||
createBookmark() {
|
createBookmark() {
|
||||||
this.selectedBookmark = null
|
this.selectedBookmark = null
|
||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = ''
|
||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
|
|
||||||
// Auto focus the input and select the text
|
|
||||||
this.$nextTick(() => {
|
|
||||||
if (this.$refs.noteInput?.$refs.input?.$refs.input) {
|
|
||||||
this.$refs.noteInput.$refs.input.$refs.input.focus()
|
|
||||||
setTimeout(() => {
|
|
||||||
this.$refs.noteInput?.$refs.input?.$refs.input?.select()
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async submitBookmark() {
|
async submitBookmark() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="400" height="100%">
|
<modals-modal v-model="show" :width="400" max-width="95%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||||
@@ -8,19 +8,31 @@
|
|||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||||
|
<div class="sticky top-0 z-10 bg-secondary grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 border-b border-fg/10 transition-shadow" :class="{ 'shadow-md': isScrolled }">
|
||||||
|
<div>
|
||||||
|
<p class="text-fg-muted text-sm">{{ $strings.LabelChapters }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelStart }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelDuration }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="chapter in chapters">
|
<li v-for="chapter in chapters" :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
<div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
|
||||||
<div class="relative flex items-center pl-3 pr-20">
|
<p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
|
||||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
<div class="text-right" style="min-width: 60px">
|
||||||
<div class="absolute top-0 right-3 -mt-0.5">
|
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-right" style="min-width: 60px">
|
||||||
|
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start) / _playbackRate) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||||
</li>
|
</li>
|
||||||
</template>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +54,9 @@ export default {
|
|||||||
playbackRate: Number
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
isScrolled: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
value(newVal) {
|
||||||
@@ -87,8 +101,25 @@ export default {
|
|||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
handleScroll() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
this.isScrolled = container.scrollTop > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('scroll', this.handleScroll)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
const container = this.$refs.container
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('scroll', this.handleScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -54,8 +54,39 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sublist: null,
|
sublist: null
|
||||||
bookItems: [
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (!newVal) {
|
||||||
|
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||||
|
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.filterBy
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('update:filterBy', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userCanAccessExplicitContent() {
|
||||||
|
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||||
|
},
|
||||||
|
bookItems() {
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -104,9 +135,26 @@ export default {
|
|||||||
text: this.$strings.ButtonIssues,
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
sublist: false
|
sublist: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
|
value: 'feed-open',
|
||||||
|
sublist: false
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
podcastItems: [
|
|
||||||
|
if (this.userCanAccessExplicitContent) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
podcastItems() {
|
||||||
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelAll,
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
@@ -120,34 +168,23 @@ export default {
|
|||||||
text: this.$strings.LabelTag,
|
text: this.$strings.LabelTag,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRSSFeedOpen,
|
||||||
|
value: 'feed-open',
|
||||||
|
sublist: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
|
||||||
},
|
if (this.userCanAccessExplicitContent) {
|
||||||
watch: {
|
items.push({
|
||||||
show(newVal) {
|
text: this.$strings.LabelExplicit,
|
||||||
if (!newVal) {
|
value: 'explicit',
|
||||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
sublist: false
|
||||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.value
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('input', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
get() {
|
|
||||||
return this.filterBy
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$emit('update:filterBy', val)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="threeQuartersScreen ? 'h-[75vh] min-h-[400px] short:min-h-0 short:h-screen' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export default {
|
|||||||
// Show confirm if item has progress since it will reset
|
// Show confirm if item has progress since it will reset
|
||||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmMarkAsFinished
|
message: this.$strings.MessageConfirmMarkAsFinished
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
@@ -369,7 +369,7 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDiscardProgress
|
message: this.$strings.MessageConfirmDiscardProgress
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -407,7 +407,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -432,7 +432,7 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||||
})
|
})
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%">
|
<modals-modal v-model="show" width="90%">
|
||||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
<ul class="w-full rounded-lg text-base max-h-[70vh] overflow-y-auto overscroll-contain" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -64,6 +64,18 @@ export default {
|
|||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgress,
|
||||||
|
value: 'progress'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgressStarted,
|
||||||
|
value: 'progress.createdAt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLibrarySortByProgressFinished,
|
||||||
|
value: 'progress.finishedAt'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelRandomly,
|
text: this.$strings.LabelRandomly,
|
||||||
value: 'random'
|
value: 'random'
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default {
|
|||||||
async cancelSleepTimer() {
|
async cancelSleepTimer() {
|
||||||
if (this.isAuto) {
|
if (this.isAuto) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
|
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
|
||||||
|
|
||||||
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
<ui-text-input v-model="fullFeedUrl" class="text-sm" readonly />
|
||||||
|
|
||||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(fullFeedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentFeed.meta" class="mt-5">
|
<div v-if="currentFeed.meta" class="mt-5">
|
||||||
@@ -119,6 +119,10 @@ export default {
|
|||||||
demoFeedUrl() {
|
demoFeedUrl() {
|
||||||
return `${this.serverAddress}/feed/${this.newFeedSlug}`
|
return `${this.serverAddress}/feed/${this.newFeedSlug}`
|
||||||
},
|
},
|
||||||
|
fullFeedUrl() {
|
||||||
|
if (!this.currentFeed) return ''
|
||||||
|
return `${this.serverAddress}${this.currentFeed.feedUrl}`
|
||||||
|
},
|
||||||
isHttp() {
|
isHttp() {
|
||||||
return !!this.serverAddress?.startsWith('http://')
|
return !!this.serverAddress?.startsWith('http://')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
},
|
},
|
||||||
@@ -245,11 +242,9 @@ export default {
|
|||||||
async extract() {
|
async extract() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
|
// TODO: Handle JWT auth refresh
|
||||||
const buff = await this.$axios.$get(this.url, {
|
const buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const archive = await Archive.open(buff)
|
const archive = await Archive.open(buff)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default {
|
|||||||
isRefreshingUI: false,
|
isRefreshingUI: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
textStroke: 0
|
textStroke: 0
|
||||||
@@ -49,9 +50,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
},
|
|
||||||
/** @returns {string} */
|
/** @returns {string} */
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem?.id
|
return this.libraryItem?.id
|
||||||
@@ -137,6 +135,7 @@ export default {
|
|||||||
|
|
||||||
const fontScale = settings.fontScale || 100
|
const fontScale = settings.fontScale || 100
|
||||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||||
|
this.rendition.themes.font(settings.font)
|
||||||
this.rendition.spread(settings.spread || 'auto')
|
this.rendition.spread(settings.spread || 'auto')
|
||||||
},
|
},
|
||||||
goToChapter(href) {
|
goToChapter(href) {
|
||||||
@@ -298,15 +297,26 @@ export default {
|
|||||||
|
|
||||||
/** @type {EpubReader} */
|
/** @type {EpubReader} */
|
||||||
const reader = this
|
const reader = this
|
||||||
|
|
||||||
|
// Use axios to make request because we have token refresh logic in interceptor
|
||||||
|
const customRequest = async (url) => {
|
||||||
|
try {
|
||||||
|
return this.$axios.$get(url, {
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[EpubReader] initEpub', reader.url)
|
console.log('[EpubReader] initEpub', reader.url)
|
||||||
/** @type {ePub.Book} */
|
/** @type {ePub.Book} */
|
||||||
reader.book = new ePub(reader.url, {
|
reader.book = new ePub(reader.url, {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - this.readerHeightOffset,
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
openAs: 'epub',
|
openAs: 'epub',
|
||||||
requestHeaders: {
|
requestMethod: this.isLocal ? null : customRequest
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** @type {ePub.Rendition} */
|
/** @type {ePub.Rendition} */
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {},
|
||||||
userToken() {
|
|
||||||
return this.$store.getters['user/getToken']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
addHtmlCss() {
|
addHtmlCss() {
|
||||||
let iframe = document.getElementsByTagName('iframe')[0]
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
@@ -84,11 +80,9 @@ export default {
|
|||||||
},
|
},
|
||||||
async initMobi() {
|
async initMobi() {
|
||||||
// Fetch mobi file as blob
|
// Fetch mobi file as blob
|
||||||
|
// TODO: Handle JWT auth refresh
|
||||||
var buff = await this.$axios.$get(this.url, {
|
var buff = await this.$axios.$get(this.url, {
|
||||||
responseType: 'blob',
|
responseType: 'blob'
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
var reader = new FileReader()
|
var reader = new FileReader()
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export default {
|
|||||||
numPages: 0,
|
numPages: 0,
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
pdfDocInitParams: null
|
pdfDocInitParams: null,
|
||||||
|
isRefreshing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -109,6 +110,10 @@ export default {
|
|||||||
},
|
},
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
return this.$store.getters['getIsPlayerOpen']
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
|
},
|
||||||
|
ebookUrl() {
|
||||||
|
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||||
|
return this.isLocal ? this.url : `${serverAddress}${this.url}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -164,7 +169,56 @@ export default {
|
|||||||
this.page++
|
this.page++
|
||||||
this.updateProgress()
|
this.updateProgress()
|
||||||
},
|
},
|
||||||
error(err) {
|
async handleRefreshFailure() {
|
||||||
|
try {
|
||||||
|
console.log('[PdfReader] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
const serverConnectionConfigId = this.$store.getters['user/getServerConnectionConfigId']
|
||||||
|
|
||||||
|
// Clear store
|
||||||
|
await this.$store.dispatch('user/logout')
|
||||||
|
|
||||||
|
if (serverConnectionConfigId) {
|
||||||
|
// Clear refresh token for server connection config
|
||||||
|
await this.$db.clearRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect?error=refreshTokenFailed&serverConnectionConfigId=' + serverConnectionConfigId
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PdfReader] Failed to handle refresh failure:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshToken() {
|
||||||
|
if (this.isRefreshing) return
|
||||||
|
this.isRefreshing = true
|
||||||
|
// Cannot use axios with this pdf reader so we need to handle the refresh separately
|
||||||
|
// Should work on migrating to a different pdf reader in the future
|
||||||
|
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||||
|
console.error('Failed to refresh token', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!newAccessToken) {
|
||||||
|
this.handleRefreshFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Vue to re-render the PDF component by creating a new object
|
||||||
|
this.pdfDocInitParams = {
|
||||||
|
url: this.ebookUrl,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${newAccessToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isRefreshing = false
|
||||||
|
},
|
||||||
|
async error(err) {
|
||||||
|
if (err && err.status === 401) {
|
||||||
|
console.log('Received 401 error, refreshing token')
|
||||||
|
await this.refreshToken()
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
screenOrientationChange() {
|
||||||
@@ -173,7 +227,7 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.pdfDocInitParams = {
|
this.pdfDocInitParams = {
|
||||||
url: this.url,
|
url: this.ebookUrl,
|
||||||
httpHeaders: {
|
httpHeaders: {
|
||||||
Authorization: `Bearer ${this.userToken}`
|
Authorization: `Bearer ${this.userToken}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||||
<!-- toolbar -->
|
<!-- toolbar -->
|
||||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
<div class="w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="`${showingToolbar ? 'translate-y-0' : '-translate-y-36'} ${isIos ? 'pt-14 h-36' : 'pt-10 h-32'}`" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||||
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<span class="material-symbols text-2xl text-fg">settings</span>
|
<span class="material-symbols text-2xl text-fg">settings</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||||
<span class="material-symbols text-2xl text-fg">more-vert</span>
|
<span class="material-symbols text-2xl text-fg">more</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="material-symbols">close</span>
|
<span class="material-symbols">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
<div class="w-full overflow-y-auto overflow-x-hidden h-[calc(75vh-85px)] min-h-[320px] short:min-h-0 short:h-[calc(100vh-85px)]">
|
||||||
<div class="w-full h-full px-4">
|
<div class="w-full h-full px-4">
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
@@ -70,6 +70,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center mb-6">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-sm">{{ $strings.LabelFontFamily }}</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.font" name="font" :items="fontItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
<div class="flex items-center mb-6">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
||||||
@@ -141,6 +147,7 @@ export default {
|
|||||||
isInittingWatchVolume: false,
|
isInittingWatchVolume: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
|
font: 'serif',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115,
|
lineSpacing: 115,
|
||||||
spread: 'auto',
|
spread: 'auto',
|
||||||
@@ -180,6 +187,9 @@ export default {
|
|||||||
this.$store.commit('setShowReader', val)
|
this.$store.commit('setShowReader', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isIos() {
|
||||||
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.mediaMetadata.title || 'No Title'
|
return this.mediaMetadata.title || 'No Title'
|
||||||
},
|
},
|
||||||
@@ -252,6 +262,18 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
fontItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFontFamilySans,
|
||||||
|
value: 'sans-serif'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelFontFamilySerif,
|
||||||
|
value: 'serif'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
readerComponentName() {
|
readerComponentName() {
|
||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
@@ -260,6 +282,7 @@ export default {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
ebookFile() {
|
ebookFile() {
|
||||||
|
if (!this.media) return null
|
||||||
// ebook file id is passed when reading a supplementary ebook
|
// ebook file id is passed when reading a supplementary ebook
|
||||||
if (this.ebookFileId) {
|
if (this.ebookFileId) {
|
||||||
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
||||||
@@ -304,11 +327,11 @@ export default {
|
|||||||
if (this.localContentUrl) {
|
if (this.localContentUrl) {
|
||||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||||
}
|
}
|
||||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
|
||||||
if (this.ebookFileId) {
|
if (this.ebookFileId) {
|
||||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||||
}
|
}
|
||||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
|
return `/api/items/${this.selectedLibraryItem.id}/ebook`
|
||||||
},
|
},
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
return this.$store.getters['getIsPlayerOpen']
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
|
|||||||
@@ -183,10 +183,16 @@ export default {
|
|||||||
daysInARow() {
|
daysInARow() {
|
||||||
var count = 0
|
var count = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
var _date = this.$addDaysToToday(count * -1)
|
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||||
|
|
||||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||||
|
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||||
|
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||||
|
if (this.listeningStatsDays[today]) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
count++
|
count++
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||||
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
||||||
|
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
<td>
|
<td>
|
||||||
@@ -23,6 +24,9 @@
|
|||||||
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ export default {
|
|||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
showFiltersModal: false,
|
showFiltersModal: false,
|
||||||
showSortModal: false,
|
showSortModal: false,
|
||||||
sortKey: 'publishedAt',
|
|
||||||
sortDesc: true,
|
|
||||||
filterKey: 'incomplete',
|
|
||||||
fetchingRSSFeed: false,
|
fetchingRSSFeed: false,
|
||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
showPodcastEpisodeFeed: false,
|
showPodcastEpisodeFeed: false,
|
||||||
@@ -223,13 +220,34 @@ export default {
|
|||||||
if (!this.sortKey) return ''
|
if (!this.sortKey) return ''
|
||||||
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||||
return _sel?.text || ''
|
return _sel?.text || ''
|
||||||
|
},
|
||||||
|
filterKey() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('podcastEpisodesFilterBy') || 'incomplete'
|
||||||
|
},
|
||||||
|
sortKey: {
|
||||||
|
get() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('podcastEpisodesOrderBy') || 'publishedAt'
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderBy: val })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sortDesc: {
|
||||||
|
get() {
|
||||||
|
const desc = this.$store.getters['user/getUserSetting']('podcastEpisodesOrderDesc')
|
||||||
|
if (desc == null) return this.mediaMetadata.type === 'episodic'
|
||||||
|
return desc
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderDesc: val })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async clearDownloadQueue() {
|
async clearDownloadQueue() {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: this.$strings.HeaderConfirm,
|
||||||
message: `Are you sure you want to clear episode download queue?`
|
message: this.$strings.MessageConfirmDeleteEpisodeDownloadQueue
|
||||||
})
|
})
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -277,8 +295,8 @@ export default {
|
|||||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||||
},
|
},
|
||||||
setFilter(filter) {
|
setFilter(filter) {
|
||||||
this.filterKey = filter
|
|
||||||
this.showFiltersModal = false
|
this.showFiltersModal = false
|
||||||
|
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: filter })
|
||||||
},
|
},
|
||||||
showFilters() {
|
showFilters() {
|
||||||
this.showFiltersModal = true
|
this.showFiltersModal = true
|
||||||
@@ -291,7 +309,6 @@ export default {
|
|||||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.sortDesc = this.mediaMetadata.type === 'episodic'
|
|
||||||
this.episodesCopy = this.episodes.map((ep) => {
|
this.episodesCopy = this.episodes.map((ep) => {
|
||||||
return { ...ep }
|
return { ...ep }
|
||||||
})
|
})
|
||||||
@@ -316,7 +333,7 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.$route.query['episodefilter'] === 'downloaded') {
|
if (this.$route.query['episodefilter'] === 'downloaded') {
|
||||||
this.filterKey = 'downloaded'
|
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: 'downloaded' })
|
||||||
}
|
}
|
||||||
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
||||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
|
<input v-model="input" ref="input" :autofocus="autofocus" :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
|
||||||
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
||||||
<span class="material-symbols text-lg">{{ prependIcon }}</span>
|
<span class="material-symbols text-lg">{{ prependIcon }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,6 +22,10 @@ export default {
|
|||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
borderless: Boolean,
|
borderless: Boolean,
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
bg: {
|
bg: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'bg'
|
default: 'bg'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
|
<p class="pb-0.5 text-sm font-semibold">{{ label }}</p>
|
||||||
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" text-size="base" class="w-full" />
|
<ui-text-input v-model="inputValue" ref="input" :disabled="disabled" :type="type" :placeholder="placeholder" :autofocus="autofocus" text-size="base" class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,7 +14,12 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
placeholder: String,
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||||
|
<path
|
||||||
|
fill="white"
|
||||||
|
d="M 89.00,40.12
|
||||||
|
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||||
|
127.00,40.12 198.00,40.12 198.00,40.12
|
||||||
|
198.00,40.12 416.00,40.12 416.00,40.12
|
||||||
|
446.58,40.05 472.95,66.42 473.00,97.00
|
||||||
|
473.00,97.00 473.00,303.00 473.00,303.00
|
||||||
|
473.00,303.00 473.00,418.00 473.00,418.00
|
||||||
|
472.65,447.55 445.06,472.95 416.00,473.00
|
||||||
|
416.00,473.00 210.00,473.00 210.00,473.00
|
||||||
|
210.00,473.00 95.00,473.00 95.00,473.00
|
||||||
|
65.45,472.65 40.05,445.06 40.00,416.00
|
||||||
|
40.00,416.00 40.00,136.00 40.00,136.00
|
||||||
|
40.00,136.00 40.00,109.00 40.00,109.00
|
||||||
|
40.00,109.00 40.00,96.00 40.00,96.00
|
||||||
|
40.07,81.58 46.89,67.14 57.01,57.01
|
||||||
|
61.17,52.86 64.86,50.13 70.00,47.31
|
||||||
|
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||||
|
M 372.00,392.00
|
||||||
|
C 372.00,392.00 364.02,364.00 364.02,364.00
|
||||||
|
364.02,364.00 350.72,319.00 350.72,319.00
|
||||||
|
350.72,319.00 310.42,183.00 310.42,183.00
|
||||||
|
310.42,183.00 296.86,137.00 296.86,137.00
|
||||||
|
296.86,137.00 291.30,121.99 291.30,121.99
|
||||||
|
291.30,121.99 284.00,121.00 284.00,121.00
|
||||||
|
284.00,121.00 230.00,121.00 230.00,121.00
|
||||||
|
230.00,121.00 222.51,122.02 222.51,122.02
|
||||||
|
222.51,122.02 216.86,137.00 216.86,137.00
|
||||||
|
216.86,137.00 203.28,183.00 203.28,183.00
|
||||||
|
203.28,183.00 163.28,318.00 163.28,318.00
|
||||||
|
163.28,318.00 148.71,367.00 148.71,367.00
|
||||||
|
148.71,367.00 142.00,392.00 142.00,392.00
|
||||||
|
142.00,392.00 183.00,392.00 183.00,392.00
|
||||||
|
183.00,392.00 190.86,390.43 190.86,390.43
|
||||||
|
190.86,390.43 195.86,375.00 195.86,375.00
|
||||||
|
195.86,375.00 206.00,338.00 206.00,338.00
|
||||||
|
206.00,338.00 293.00,338.00 293.00,338.00
|
||||||
|
295.64,338.01 299.26,337.65 301.30,339.60
|
||||||
|
303.23,341.43 304.80,348.22 305.58,351.00
|
||||||
|
305.58,351.00 313.00,378.00 313.00,378.00
|
||||||
|
316.91,391.63 315.20,391.98 325.00,392.00
|
||||||
|
325.00,392.00 372.00,392.00 372.00,392.00 Z
|
||||||
|
M 254.00,170.00
|
||||||
|
C 254.00,170.00 256.00,170.00 256.00,170.00
|
||||||
|
256.00,170.00 263.12,197.00 263.12,197.00
|
||||||
|
263.12,197.00 282.88,268.00 282.88,268.00
|
||||||
|
282.88,268.00 290.00,296.00 290.00,296.00
|
||||||
|
290.00,296.00 219.00,296.00 219.00,296.00
|
||||||
|
219.00,296.00 230.58,253.00 230.58,253.00
|
||||||
|
230.58,253.00 254.00,170.00 254.00,170.00 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -12,7 +12,8 @@ export default {
|
|||||||
return {
|
return {
|
||||||
downloadItemListener: null,
|
downloadItemListener: null,
|
||||||
completeListener: null,
|
completeListener: null,
|
||||||
itemPartUpdateListener: null
|
itemPartUpdateListener: null,
|
||||||
|
queueChangedListener: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -76,17 +77,22 @@ export default {
|
|||||||
},
|
},
|
||||||
onDownloadItemPartUpdate(itemPart) {
|
onDownloadItemPartUpdate(itemPart) {
|
||||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||||
|
},
|
||||||
|
onQueueChanged(data) {
|
||||||
|
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||||
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||||
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
|
||||||
|
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.downloadItemListener) this.downloadItemListener.remove()
|
this.downloadItemListener?.remove()
|
||||||
if (this.completeListener) this.completeListener.remove()
|
this.completeListener?.remove()
|
||||||
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
|
this.itemPartUpdateListener?.remove()
|
||||||
|
this.queueChangedListener?.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>Zur Verwendung mit Ihrem Audiobookshelf-Server. Audiobookshelf ist ein kostenloser Open-Source-Hörbuch- und Podcast-Manager.</p><p>Verbinden Sie sich mit Ihrem selbst gehosteten Audiobookshelf-Server und streamen Sie Ihre Hörbücher und Podcasts direkt auf Ihr Gerät.</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Hörbuch- und Podcast-Player für Ihren selbst gehosteten Audiobookshelf-Server
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>For use with your audiobookshelf server. Audiobookshelf is a free and open source audiobook and podcast manager.</p><p>Connect to your self-hosted audiobookshelf server and stream your audiobooks and podcasts directly to your device.</p>
|
||||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Audiobook and podcast player for your self-hosted Audiobookshelf server
|
||||||
@@ -15,20 +15,18 @@
|
|||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */; };
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */; };
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF618E2804325C0070250E /* PlayerHandler.swift */; };
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ABF618E2804325C0070250E /* PlayerHandler.swift */; };
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */; };
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE428043E50006DB301 /* AbsDatabase.swift */; };
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE628043E72006DB301 /* AbsDatabase.m */; };
|
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */; };
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */; };
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEA280443DD006DB301 /* Database.swift */; };
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEA280443DD006DB301 /* Database.swift */; };
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEC28044E6C006DB301 /* Store.swift */; };
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD4FCEC28044E6C006DB301 /* Store.swift */; };
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970B2806E2590096F747 /* ApiClient.swift */; };
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970B2806E2590096F747 /* ApiClient.swift */; };
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; };
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */; };
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */; };
|
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; };
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */; };
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B951282EE822008272D4 /* AbsDownloader.m */; };
|
|
||||||
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
4D66B954282EE87C008272D4 /* AbsDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B953282EE87C008272D4 /* AbsDownloader.swift */; };
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B955282EE951008272D4 /* AbsFileSystem.m */; };
|
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; };
|
||||||
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
|
4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; };
|
||||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; };
|
||||||
|
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; };
|
||||||
|
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; };
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; };
|
||||||
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
|
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; };
|
||||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||||
@@ -38,6 +36,7 @@
|
|||||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
||||||
|
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2516262E68DA3000F40541 /* LogEntry.swift */; };
|
||||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||||
@@ -90,21 +89,19 @@
|
|||||||
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSession.swift; sourceTree = "<group>"; };
|
3ABF580828059BAE005DFBE5 /* PlaybackSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSession.swift; sourceTree = "<group>"; };
|
||||||
3ABF618E2804325C0070250E /* PlayerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerHandler.swift; sourceTree = "<group>"; };
|
3ABF618E2804325C0070250E /* PlayerHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerHandler.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDatabase.swift; sourceTree = "<group>"; };
|
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDatabase.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCE628043E72006DB301 /* AbsDatabase.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDatabase.m; sourceTree = "<group>"; };
|
|
||||||
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionConfig.swift; sourceTree = "<group>"; };
|
3AD4FCE828043FD7006DB301 /* ServerConnectionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnectionConfig.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCEA280443DD006DB301 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
|
3AD4FCEA280443DD006DB301 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
|
||||||
3AD4FCEC28044E6C006DB301 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
3AD4FCEC28044E6C006DB301 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = "<group>"; };
|
||||||
3AF1970B2806E2590096F747 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
|
3AF1970B2806E2590096F747 /* ApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiClient.swift; sourceTree = "<group>"; };
|
||||||
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; };
|
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsAudioPlayer.swift; sourceTree = "<group>"; };
|
||||||
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsAudioPlayer.m; sourceTree = "<group>"; };
|
|
||||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; };
|
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingInfo.swift; sourceTree = "<group>"; };
|
||||||
4D66B951282EE822008272D4 /* AbsDownloader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsDownloader.m; sourceTree = "<group>"; };
|
|
||||||
4D66B953282EE87C008272D4 /* AbsDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDownloader.swift; sourceTree = "<group>"; };
|
4D66B953282EE87C008272D4 /* AbsDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsDownloader.swift; sourceTree = "<group>"; };
|
||||||
4D66B955282EE951008272D4 /* AbsFileSystem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AbsFileSystem.m; sourceTree = "<group>"; };
|
|
||||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsFileSystem.swift; sourceTree = "<group>"; };
|
||||||
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
|
4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = "<group>"; };
|
||||||
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
|
4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
|
||||||
|
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = "<group>"; };
|
||||||
|
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = "<group>"; };
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = "<group>"; };
|
||||||
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
|
4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = "<group>"; };
|
||||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +112,7 @@
|
|||||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||||
|
6F2516262E68DA3000F40541 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
|
||||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -217,13 +215,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */,
|
3AD4FCE428043E50006DB301 /* AbsDatabase.swift */,
|
||||||
3AD4FCE628043E72006DB301 /* AbsDatabase.m */,
|
|
||||||
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */,
|
3AF1970D2806E3CA0096F747 /* AbsAudioPlayer.swift */,
|
||||||
3AF1970F2806E3DC0096F747 /* AbsAudioPlayer.m */,
|
|
||||||
4D66B951282EE822008272D4 /* AbsDownloader.m */,
|
|
||||||
4D66B953282EE87C008272D4 /* AbsDownloader.swift */,
|
4D66B953282EE87C008272D4 /* AbsDownloader.swift */,
|
||||||
4D66B955282EE951008272D4 /* AbsFileSystem.m */,
|
|
||||||
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */,
|
4D66B957282EEA14008272D4 /* AbsFileSystem.swift */,
|
||||||
|
4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */,
|
||||||
);
|
);
|
||||||
path = plugins;
|
path = plugins;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -238,6 +233,7 @@
|
|||||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||||
|
6F2516262E68DA3000F40541 /* LogEntry.swift */,
|
||||||
E9D5506328AC1D3F00C746DD /* server */,
|
E9D5506328AC1D3F00C746DD /* server */,
|
||||||
E9D5506428AC1D5800C746DD /* local */,
|
E9D5506428AC1D5800C746DD /* local */,
|
||||||
E9D5506D28AC1E7400C746DD /* download */,
|
E9D5506D28AC1E7400C746DD /* download */,
|
||||||
@@ -248,6 +244,7 @@
|
|||||||
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
3AFCB5E627EA23F700ECCC05 /* util */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */,
|
||||||
E9FA07E228C82848005520B0 /* Logger.swift */,
|
E9FA07E228C82848005520B0 /* Logger.swift */,
|
||||||
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */,
|
||||||
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
3AD4FCEA280443DD006DB301 /* Database.swift */,
|
||||||
@@ -531,7 +528,6 @@
|
|||||||
files = (
|
files = (
|
||||||
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
E9D5507328AC218300C746DD /* DaoExtensions.swift in Sources */,
|
||||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
||||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||||
@@ -540,11 +536,13 @@
|
|||||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||||
|
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
|
||||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||||
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
E9D5504C28AC1AE000C746DD /* PodcastEpisode.swift in Sources */,
|
||||||
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
E9D5506A28AC1DF100C746DD /* LocalFile.swift in Sources */,
|
||||||
|
4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */,
|
||||||
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
3AF1970E2806E3CA0096F747 /* AbsAudioPlayer.swift in Sources */,
|
||||||
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
E9D5506F28AC1E8E00C746DD /* DownloadItem.swift in Sources */,
|
||||||
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
3AD4FCE928043FD7006DB301 /* ServerConnectionConfig.swift in Sources */,
|
||||||
@@ -553,15 +551,14 @@
|
|||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */,
|
4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */,
|
||||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
|
||||||
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
3AD4FCEB280443DD006DB301 /* Database.swift in Sources */,
|
||||||
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */,
|
||||||
|
4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */,
|
||||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
|
||||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
@@ -574,7 +571,6 @@
|
|||||||
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
E9D5505C28AC1C6200C746DD /* LibraryFile.swift in Sources */,
|
||||||
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */,
|
||||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */,
|
||||||
3AF197102806E3DC0096F747 /* AbsAudioPlayer.m in Sources */,
|
|
||||||
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
E9D5507528AEF93100C746DD /* PlayerSettings.swift in Sources */,
|
||||||
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
E9D5505028AC1B3E00C746DD /* Author.swift in Sources */,
|
||||||
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
3AF1970C2806E2590096F747 /* ApiClient.swift in Sources */,
|
||||||
@@ -744,12 +740,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.79;
|
MARKETING_VERSION = 0.13.0;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -768,12 +764,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 36;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.79;
|
MARKETING_VERSION = 0.13.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||