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 |
@@ -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. -->
|
||||||
@@ -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 111
|
versionCode 117
|
||||||
versionName "0.9.80-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
|
||||||
@@ -35,23 +40,8 @@ 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)
|
||||||
@@ -63,13 +53,62 @@ class MainActivity : BridgeActivity() {
|
|||||||
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,52 +109,53 @@ 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,
|
||||||
@@ -131,7 +163,7 @@ data class DeviceSettings(
|
|||||||
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,
|
||||||
@@ -144,7 +176,7 @@ data class DeviceSettings(
|
|||||||
) {
|
) {
|
||||||
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,
|
||||||
@@ -175,21 +207,26 @@ data class DeviceSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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.1f
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||||
@@ -203,8 +240,8 @@ 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?
|
||||||
) {
|
) {
|
||||||
@@ -215,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 ?: "")
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ object DeviceManager {
|
|||||||
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
|
||||||
|
|
||||||
@@ -111,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 =
|
|
||||||
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
|
|
||||||
)
|
|
||||||
} 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()
|
|
||||||
var foundEBookFile = false
|
|
||||||
|
|
||||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
|
||||||
)
|
|
||||||
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
|
||||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
|
||||||
|
|
||||||
if (file == null) {
|
|
||||||
Log.e(
|
|
||||||
tag,
|
|
||||||
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
if (downloadItemPart.audioTrack != null) {
|
|
||||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
|
||||||
)
|
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
|
||||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
|
||||||
val localFile =
|
|
||||||
LocalFile(
|
|
||||||
localFileId,
|
|
||||||
file.name,
|
file.name,
|
||||||
downloadItemPart.finalDestinationUri.toString(),
|
Uri.fromFile(file).toString(),
|
||||||
file.getBasePath(ctx),
|
file.getBasePath(ctx),
|
||||||
file.absolutePath,
|
file.absolutePath,
|
||||||
file.getSimplePath(ctx),
|
|
||||||
file.mimeType,
|
file.mimeType,
|
||||||
file.length()
|
file.length()
|
||||||
)
|
)
|
||||||
localLibraryItem.localFiles.add(localFile)
|
}
|
||||||
|
|
||||||
val trackFileMetadata =
|
part.completedDestinationUri?.let { contentUrl ->
|
||||||
FileMetadata(
|
val uri = Uri.parse(contentUrl)
|
||||||
file.name,
|
val size =
|
||||||
file.extension,
|
try {
|
||||||
file.absolutePath,
|
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
file.getBasePath(ctx),
|
descriptor.statSize.coerceAtLeast(0L)
|
||||||
file.length()
|
}
|
||||||
|
?: 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||||
|
// 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newLocalLibraryItem(
|
||||||
|
id: String,
|
||||||
|
downloadItem: DownloadItem,
|
||||||
|
basePath: String,
|
||||||
|
absolutePath: String,
|
||||||
|
contentUrl: String
|
||||||
|
) =
|
||||||
|
LocalLibraryItem(
|
||||||
|
id,
|
||||||
|
downloadItem.localFolder.id,
|
||||||
|
basePath,
|
||||||
|
absolutePath,
|
||||||
|
contentUrl,
|
||||||
|
false,
|
||||||
|
downloadItem.mediaType,
|
||||||
|
downloadItem.media.getLocalCopy(),
|
||||||
|
mutableListOf(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
downloadItem.serverConnectionConfigId,
|
||||||
|
downloadItem.serverAddress,
|
||||||
|
downloadItem.serverUserId,
|
||||||
|
downloadItem.libraryItemId
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scanParts(
|
||||||
|
item: DownloadItem,
|
||||||
|
localItem: LocalLibraryItem,
|
||||||
|
externalFolder: DocumentFile? = null,
|
||||||
|
callback: (DownloadItemScanResult?) -> Unit
|
||||||
|
) {
|
||||||
|
val tracks = mutableListOf<AudioTrack>()
|
||||||
|
var foundEbook = false
|
||||||
|
var localEpisodeId: String? = null
|
||||||
|
|
||||||
|
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(
|
||||||
|
localFile.filename ?: "",
|
||||||
|
File(localFile.filename ?: "").extension,
|
||||||
|
localFile.absolutePath,
|
||||||
|
localFile.basePath,
|
||||||
|
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)
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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}"
|
|
||||||
)
|
)
|
||||||
|
tracks.add(track)
|
||||||
|
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||||
|
part.episode?.let { episode ->
|
||||||
|
val podcast = localItem.media as Podcast
|
||||||
|
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||||
}
|
}
|
||||||
} else if (downloadItemPart.ebookFile != null) {
|
}
|
||||||
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}"
|
}
|
||||||
)
|
if (item.mediaType == "book") {
|
||||||
return cb(null)
|
tracks.sortBy { it.index }
|
||||||
|
var expectedIndex = 1
|
||||||
|
var offset = 0.0
|
||||||
|
tracks.forEach { track ->
|
||||||
|
track.index = expectedIndex++
|
||||||
|
track.startOffset = offset
|
||||||
|
offset += track.duration
|
||||||
|
}
|
||||||
|
localItem.media.setAudioTracks(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For books sort audio tracks then set
|
val result = DownloadItemScanResult(localItem, null)
|
||||||
if (downloadItem.mediaType == "book") {
|
item.userMediaProgress?.let { progress ->
|
||||||
audioTracks.sortBy { it.index }
|
val progressId =
|
||||||
|
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||||
var indexCheck = 1
|
else "${localItem.id}-$localEpisodeId"
|
||||||
var startOffset = 0.0
|
result.localMediaProgress =
|
||||||
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(
|
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.saveLocalLibraryItem(localItem)
|
||||||
)
|
callback(result)
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
|
||||||
|
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||||
|
if (subPath.isBlank()) return root
|
||||||
cb(downloadItemScanResult)
|
var current = root
|
||||||
|
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan item after download and create local library item
|
/**
|
||||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||||
// If downloading to internal storage handle separately
|
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||||
if (downloadItem.isInternalStorage) {
|
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||||
scanInternalDownloadItem(downloadItem, cb)
|
*/
|
||||||
|
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 (itemFolder == null) {
|
||||||
|
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
val id = localLibraryItemId(itemFolder.id)
|
||||||
if (itemFolderUrl == "") {
|
val localItem =
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
return cb(null)
|
?: newLocalLibraryItem(
|
||||||
}
|
id,
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
item,
|
||||||
|
itemFolder.getBasePath(ctx),
|
||||||
if (df == null) {
|
itemFolder.getAbsolutePath(ctx),
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
itemFolder.uri.toString()
|
||||||
return cb(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
|
||||||
Log.d(
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
|
|||||||
import com.audiobookshelf.app.plugins.AbsLog
|
import com.audiobookshelf.app.plugins.AbsLog
|
||||||
import com.audiobookshelf.app.plugins.AbsLogger
|
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"
|
||||||
@@ -148,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 ->
|
||||||
@@ -157,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>
|
||||||
|
|
||||||
@@ -203,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}"
|
||||||
@@ -290,15 +291,13 @@ class DbManager {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLog(log:AbsLog) {
|
fun saveLog(log: AbsLog) {
|
||||||
Paper.book("log").write(log.id, log)
|
Paper.book("log").write(log.id, log)
|
||||||
}
|
}
|
||||||
fun getAllLogs() : List<AbsLog> {
|
fun getAllLogs(): List<AbsLog> {
|
||||||
val logs:MutableList<AbsLog> = mutableListOf()
|
val logs: MutableList<AbsLog> = mutableListOf()
|
||||||
Paper.book("log").allKeys.forEach { logId ->
|
Paper.book("log").allKeys.forEach { logId ->
|
||||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||||
logs.add(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return logs.sortedBy { it.timestamp }
|
return logs.sortedBy { it.timestamp }
|
||||||
}
|
}
|
||||||
@@ -317,7 +316,10 @@ class DbManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (logsRemoved > 0) {
|
if (logsRemoved > 0) {
|
||||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
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)
|
||||||
|
} else if (tryReserve(part)) startDownload(item, part)
|
||||||
|
else {
|
||||||
|
part.waitingForSpace = true
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
break
|
val stagingFile = File(part.destinationPath)
|
||||||
}
|
stagingFile.parentFile?.mkdirs()
|
||||||
}
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
part.waitingForSpace = false
|
||||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
}
|
currentDownloadItemParts.add(part)
|
||||||
|
persist(item, force = true)
|
||||||
/** Processes the download item parts. */
|
val activeConfig = DeviceManager.serverConnectionConfig
|
||||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
val token =
|
||||||
nextDownloadItemParts.forEach {
|
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||||
if (it.isInternalStorage) {
|
else
|
||||||
startInternalDownload(it)
|
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||||
} else {
|
?: DeviceManager.token
|
||||||
startExternalDownload(it)
|
activeCalls[part.id] =
|
||||||
}
|
InternalDownloadManager(
|
||||||
}
|
stagingFile,
|
||||||
}
|
part.fileSize,
|
||||||
|
|
||||||
/** Starts an internal download. */
|
|
||||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
|
||||||
val file = File(downloadItemPart.finalDestinationPath)
|
|
||||||
file.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
|
||||||
val internalProgressCallback =
|
|
||||||
object : InternalProgressCallback {
|
object : InternalProgressCallback {
|
||||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
synchronized(this@DownloadItemManager) {
|
||||||
downloadItemPart.progress = progress
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.bytesDownloaded = totalBytesWritten
|
||||||
|
part.progress = progress
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onComplete(failed: Boolean) {
|
override fun onComplete(failed: Boolean) {
|
||||||
downloadItemPart.failed = failed
|
synchronized(this@DownloadItemManager) {
|
||||||
downloadItemPart.completed = true
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.failed = failed
|
||||||
|
part.completed = !failed
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
persist(item, force = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
Log.d(
|
{ hasAvailableSpace(part) }
|
||||||
tag,
|
|
||||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
|
||||||
)
|
)
|
||||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
.download(serverUrl(item, part), token)
|
||||||
.download(downloadItemPart.serverUrl)
|
|
||||||
downloadItemPart.downloadId = 1
|
|
||||||
currentDownloadItemParts.add(downloadItemPart)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts an external download. */
|
@Synchronized
|
||||||
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()
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
delay(WATCH_INTERVAL_MS)
|
||||||
Log.d(tag, "Finished watching downloads")
|
|
||||||
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 {
|
|
||||||
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")
|
||||||
val fcb =
|
part.isMoving = true
|
||||||
object : FileCallback() {
|
persist(item, force = true)
|
||||||
override fun onPrepare() {
|
scope.launch {
|
||||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
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())
|
||||||
override fun onFailed(errorCode: ErrorCode) {
|
throw IllegalStateException("SAF copy size mismatch")
|
||||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
val existing = folder.findFile(part.filename)
|
||||||
downloadItemPart.failed = true
|
if (existing != null && !existing.delete())
|
||||||
downloadItemPart.isMoving = false
|
throw IllegalStateException("Could not replace existing file")
|
||||||
file?.delete()
|
if (!temporary.renameTo(part.filename))
|
||||||
checkDownloadItemFinished(downloadItem)
|
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
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}")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCompleted(result: Any) {
|
|
||||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
|
||||||
val resultDocFile = result as DocumentFile
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rename to fix appended .mp3 on m4b/m4a files
|
|
||||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
|
||||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
|
||||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
|
||||||
) {
|
|
||||||
resultDocFile.renameTo(downloadItemPart.filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadItemPart.moved = true
|
|
||||||
downloadItemPart.isMoving = false
|
|
||||||
checkDownloadItemFinished(downloadItem)
|
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val localFolderFile =
|
@Synchronized
|
||||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
if (localFolderFile == null) {
|
Log.e(tag, message)
|
||||||
// Failed
|
part.isMoving = false
|
||||||
downloadItemPart.failed = true
|
part.failed = true
|
||||||
Log.e(tag, "Local Folder File from uri is null")
|
failOrRetry(item, part, message)
|
||||||
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. */
|
@Synchronized
|
||||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
if (downloadItem.isDownloadFinished) {
|
part.moved = true
|
||||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
part.completed = true
|
||||||
|
part.failed = false
|
||||||
|
part.isMoving = false
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
removeActivePart(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
}
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
if (!item.isDownloadFinished) return
|
||||||
Log.d(
|
scope.launch {
|
||||||
tag,
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
val event =
|
||||||
)
|
|
||||||
|
|
||||||
val jsobj =
|
|
||||||
JSObject().apply {
|
JSObject().apply {
|
||||||
put("libraryItemId", downloadItem.id)
|
put("libraryItemId", item.id)
|
||||||
put("localFolderId", downloadItem.localFolder.id)
|
put("localFolderId", item.localFolder.id)
|
||||||
|
scanResult?.localLibraryItem?.let {
|
||||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
put(
|
}
|
||||||
"localLibraryItem",
|
scanResult?.localMediaProgress?.let {
|
||||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
clientEventEmitter.onDownloadItemComplete(event)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
downloadItemQueue.remove(item)
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
|
||||||
put(
|
|
||||||
"localMediaProgress",
|
|
||||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
|
||||||
downloadItemQueue.remove(downloadItem)
|
val staging = File(part.destinationPath)
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
staging.parentFile?.mkdirs()
|
||||||
|
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||||
|
val remaining =
|
||||||
|
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||||
|
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||||
|
val key = storageKey(staging)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||||
|
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||||
|
reservations[part.destinationPath] = required
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun statFsFor(staging: File): StatFs {
|
||||||
|
var directory = staging.parentFile ?: context.filesDir
|
||||||
|
directory.mkdirs()
|
||||||
|
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||||
|
return StatFs(directory.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun storageKey(file: File): String =
|
||||||
|
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
|
||||||
|
else "external"
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun removeActivePart(part: DownloadItemPart) {
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
currentDownloadItemParts.remove(part)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||||
|
lastPersistTime[item.id] = now
|
||||||
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyQueueChanged() {
|
||||||
|
clientEventEmitter.onQueueChanged(hasWork())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
activeCalls.values.forEach(Call::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||||
|
var current = root
|
||||||
|
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? {
|
||||||
|
if (part.isInternalStorage) return null
|
||||||
|
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null
|
||||||
|
var folder = root
|
||||||
|
part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
folder = folder.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
val file = folder.findFile(part.filename) ?: return null
|
||||||
|
if (!file.isFile) return null
|
||||||
|
if (part.fileSize > 0L && file.length() != part.fileSize) return null
|
||||||
|
if (part.fileSize <= 0L && file.length() <= 0L) return null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||||
|
part.audioTrack?.mimeType
|
||||||
|
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
|
"epub" -> "application/epub+zip"
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||||
|
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
|
return "${item.serverAddress}${part.serverPath}$rawCover"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||||
|
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||||
|
const val WATCH_INTERVAL_MS = 1_000L
|
||||||
|
const val STALL_TIMEOUT_MS = 60_000L
|
||||||
|
const val MAX_RETRIES = 5
|
||||||
|
const val PERSIST_INTERVAL_MS = 2_000L
|
||||||
|
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||||
|
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Removes terminally failed downloads after their retention window elapses. */
|
||||||
|
object IncompleteDownloadCleanup {
|
||||||
|
private const val tag = "IncompleteDownloadCleanup"
|
||||||
|
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||||
|
private const val WORK_PREFIX = "incomplete-download-"
|
||||||
|
|
||||||
|
fun schedule(context: Context, item: DownloadItem) {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return
|
||||||
|
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||||
|
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||||
|
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, itemId: String) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
|
||||||
|
fun cleanupExpired(context: Context) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
DeviceManager.dbManager.getDownloadItems()
|
||||||
|
.filter { item -> isEligible(item, now) }
|
||||||
|
.forEach { item ->
|
||||||
|
deleteItem(context, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return false
|
||||||
|
if (now - failedAt < RETENTION_MS) return false
|
||||||
|
return item.downloadItemParts.all { part ->
|
||||||
|
part.moved || (part.failed && !part.isMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||||
|
if (part.isInternalStorage && part.moved) {
|
||||||
|
deleteAppOwnedFile(context, File(part.finalDestinationPath))
|
||||||
|
} else if (!part.isInternalStorage && part.moved) {
|
||||||
|
part.completedDestinationUri?.let { uriString ->
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
cancel(context, item.id)
|
||||||
|
Log.i(tag, "Deleted terminally failed download item ${item.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||||
|
val path = file.absolutePath
|
||||||
|
val internal = context.filesDir.absolutePath
|
||||||
|
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||||
|
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||||
|
if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path")
|
||||||
|
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||||
|
} else {
|
||||||
|
Log.w(tag, "Refusing to delete non-app-owned path $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
DbManager.initialize(applicationContext)
|
||||||
|
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept-Encoding", "identity")
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||||
|
.build()
|
||||||
|
val call = client.newCall(request)
|
||||||
|
call.enqueue(
|
||||||
object : Callback {
|
object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
Log.e(tag, "Download URL $url FAILED", e)
|
Log.e(tag, "Download URL failed", e)
|
||||||
progressCallback.onComplete(true)
|
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
|
||||||
}
|
}
|
||||||
?: run {
|
val append =
|
||||||
Log.e(tag, "Response doesn't contain a file")
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -527,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"
|
||||||
@@ -175,22 +176,21 @@ 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}")
|
|
||||||
populatePersonalizedDataForLibrary(it.id) {
|
|
||||||
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
|
||||||
libraryPersonalizationsDone--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (libraryPersonalizationsDone > 0) { }
|
|
||||||
|
|
||||||
|
serverLibraries.forEach { lib ->
|
||||||
|
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||||
|
populatePersonalizedDataForLibrary(lib.id) {
|
||||||
|
Log.d(tag, "Loaded personalization for library ${lib.name}")
|
||||||
|
if (remaining.decrementAndGet() == 0) {
|
||||||
Log.d(tag, "Finished loading all library personalization data")
|
Log.d(tag, "Finished loading all library personalization data")
|
||||||
allLibraryPersonalizationsDone = true
|
allLibraryPersonalizationsDone = true
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get personalized shelves from server for selected [libraryId].
|
* Get personalized shelves from server for selected [libraryId].
|
||||||
|
|||||||
@@ -7,33 +7,36 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,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
|
||||||
@@ -35,6 +35,12 @@ 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
|
||||||
@@ -59,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 {
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ import android.annotation.SuppressLint
|
|||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.net.*
|
import android.net.*
|
||||||
import android.os.*
|
import android.os.*
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import 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
|
||||||
@@ -91,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
|
||||||
@@ -308,6 +314,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
var bitmap: Bitmap? = null
|
||||||
|
// 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) {
|
||||||
|
bitmap =
|
||||||
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
|
} else {
|
||||||
|
val source: ImageDecoder.Source =
|
||||||
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
|
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
|
||||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +353,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.setExtras(extra)
|
.setExtras(extra)
|
||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||||
|
|
||||||
mediaDescriptionBuilder.setIconUri(coverUri)
|
bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) }
|
||||||
|
?: mediaDescriptionBuilder.setIconUri(coverUri)
|
||||||
|
|
||||||
return mediaDescriptionBuilder.build()
|
return mediaDescriptionBuilder.build()
|
||||||
}
|
}
|
||||||
@@ -457,6 +480,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
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
|
||||||
@@ -471,6 +496,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
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
|
||||||
@@ -685,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,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
|
||||||
@@ -1067,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)
|
||||||
@@ -1237,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}")
|
||||||
@@ -1271,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}")
|
||||||
@@ -1283,16 +1361,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
AbsLogger.info(tag, "onLoadChildren: Android auto data 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
|
||||||
|
result.detach()
|
||||||
|
waitForBrowseTree {
|
||||||
|
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>?)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already initialized: just return
|
||||||
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}")
|
||||||
MediaBrowserCompat.MediaItem(
|
MediaBrowserCompat.MediaItem(
|
||||||
item.description,
|
item.description,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -199,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.\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,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,24 @@ 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()
|
DeviceManager.dbManager.cleanLogs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,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
|
||||||
@@ -129,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!!)
|
||||||
@@ -137,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
|
||||||
@@ -163,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
|
||||||
@@ -175,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) {
|
||||||
@@ -495,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 {
|
||||||
@@ -513,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()) {
|
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
|
||||||
} else {
|
|
||||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
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,14 +24,17 @@ 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 =
|
||||||
|
object : StorageAccessCallback {
|
||||||
override fun onRootPathNotSelected(
|
override fun onRootPathNotSelected(
|
||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
rootPath: String,
|
rootPath: String,
|
||||||
@@ -45,7 +49,13 @@ class AbsFileSystem : Plugin() {
|
|||||||
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(
|
||||||
|
requestCode: Int,
|
||||||
|
selectedFolder: DocumentFile,
|
||||||
|
selectedStorageType: StorageType,
|
||||||
|
expectedBasePath: String,
|
||||||
|
expectedStorageType: StorageType
|
||||||
|
) {
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,22 +69,48 @@ class AbsFileSystem : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
fun selectFolder(call: PluginCall) {
|
fun selectFolder(call: PluginCall) {
|
||||||
val mediaType = call.data.getString("mediaType", "book").toString()
|
val mediaType = call.data.getString("mediaType", "book").toString()
|
||||||
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 =
|
||||||
|
object : FolderPickerCallback {
|
||||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||||
val absolutePath = folder.getAbsolutePath(activity)
|
val absolutePath = folder.getAbsolutePath(activity)
|
||||||
val storageType = folder.getStorageType(activity)
|
val storageType = folder.getStorageType(activity)
|
||||||
val simplePath = folder.getSimplePath(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)))
|
||||||
@@ -92,32 +128,33 @@ class AbsFileSystem : Plugin() {
|
|||||||
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?")
|
|
||||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
|
||||||
run {
|
run {
|
||||||
jsobj.put("error", "User Canceled, Access Denied")
|
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
|
||||||
|
mainActivity.storageHelper.requestStorageAccess(
|
||||||
|
REQUEST_CODE_SDCARD_ACCESS,
|
||||||
|
initialPath = FileFullPath(mainActivity, storageId, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
builder.show()
|
builder.show()
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||||
jsobj.put("error", "Access Denied")
|
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Permission Denied")
|
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||||
call.resolve(jsobj)
|
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,7 +233,8 @@ 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 =
|
||||||
|
if (file.exists()) {
|
||||||
file.deleteRecursively()
|
file.deleteRecursively()
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
@@ -207,9 +245,11 @@ class AbsFileSystem : Plugin() {
|
|||||||
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 =
|
||||||
|
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||||
if (_localLibraryItem.id == localLibraryItemId) {
|
if (_localLibraryItem.id == localLibraryItemId) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ 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.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
|
||||||
@@ -33,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>)
|
||||||
@@ -110,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")
|
||||||
@@ -142,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")) {
|
||||||
@@ -334,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,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 {
|
||||||
@@ -468,10 +767,12 @@ 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();
|
||||||
|
json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession))
|
||||||
|
json.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(deviceInfo))
|
||||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
postRequest("/api/session/local-all", payload, null) {
|
postRequest("/api/session/local-all", JSObject(json.toString()), null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -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">
|
<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>
|
||||||
@@ -100,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 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>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cast button - Chromecast toggle -->
|
||||||
|
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||||
|
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player options menu button -->
|
||||||
|
<div class="top-6 right-4 absolute cursor-pointer">
|
||||||
|
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
},
|
},
|
||||||
refreshUI() {
|
measureAndUpdateTrackWidth() {
|
||||||
this.updateScreenSize()
|
if (!this.$refs.track) {
|
||||||
if (this.$refs.track) {
|
console.error('Track not loaded', this.$refs)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
}
|
},
|
||||||
|
refreshUI() {
|
||||||
|
this.updateScreenSize()
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.measureAndUpdateTrackWidth()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
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;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isIos() {
|
isIos() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
|
},
|
||||||
|
currentPlaybackSession() {
|
||||||
|
return this.$store.state.currentPlaybackSession
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -226,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()
|
||||||
}
|
}
|
||||||
@@ -304,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 (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 (hasFocus) {
|
|
||||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
|
||||||
const playbackSession = this.$store.state.currentPlaybackSession
|
|
||||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||||
const localEpisodeId = playbackSession.localEpisodeId
|
if (!localMediaProgress) {
|
||||||
if (!localLibraryItemId) {
|
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
|
||||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
return mp.localLibraryItemId === localLibraryItemId
|
}
|
||||||
})
|
},
|
||||||
if (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)
|
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.currentTime = localMediaProgress.currentTime
|
||||||
this.$refs.audioPlayer.timeupdate()
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
|
||||||
|
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
// 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')
|
||||||
} else {
|
const data = await this.getServerMediaProgressForCurrentSession()
|
||||||
const libraryItemId = playbackSession.libraryItemId
|
if (!data) return
|
||||||
const episodeId = playbackSession.episodeId
|
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||||
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)
|
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
// Only seek if the difference is greater than 1 second
|
||||||
this.$refs.audioPlayer.timeupdate()
|
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||||
}
|
// Use seek() so the native audio player's current session is updated
|
||||||
})
|
this.$refs.audioPlayer.seek(data.currentTime)
|
||||||
.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}`)
|
||||||
@@ -368,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)
|
||||||
@@ -383,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,14 +131,12 @@ export default {
|
|||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$platform !== 'ios') {
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'bug_report',
|
icon: 'bug_report',
|
||||||
iconOutlined: true,
|
iconOutlined: true,
|
||||||
text: this.$strings.ButtonLogs,
|
text: this.$strings.ButtonLogs,
|
||||||
to: '/logs'
|
to: '/logs'
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (this.serverConnectionConfig) {
|
if (this.serverConnectionConfig) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -179,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` }">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.submit(true)) {
|
||||||
this.showForm = true
|
this.showForm = true
|
||||||
this.showAuth = 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
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.userId = user.id
|
// 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.token = user.token
|
||||||
this.serverConfig.username = user.username
|
}
|
||||||
delete this.serverConfig.version
|
|
||||||
|
this.serverConfig.version = serverSettings.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 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>
|
</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) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelExplicit,
|
||||||
|
value: 'explicit',
|
||||||
|
sublist: false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
watch: {
|
return items
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
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 */; };
|
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 */; };
|
||||||
@@ -35,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 */; };
|
||||||
@@ -98,6 +100,7 @@
|
|||||||
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>"; };
|
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>"; };
|
||||||
@@ -109,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>"; };
|
||||||
@@ -229,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 */,
|
||||||
@@ -239,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 */,
|
||||||
@@ -530,6 +536,7 @@
|
|||||||
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 */,
|
||||||
@@ -550,6 +557,7 @@
|
|||||||
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 */,
|
||||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||||
@@ -732,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 = 37;
|
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.80;
|
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)";
|
||||||
@@ -756,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 = 37;
|
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.80;
|
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 = "";
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import RealmSwift
|
|||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AppDelegate")
|
|
||||||
|
|
||||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||||
var backgroundCompletionHandler: (() -> Void)?
|
var backgroundCompletionHandler: (() -> Void)?
|
||||||
|
|
||||||
@@ -14,16 +12,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 19,
|
schemaVersion: 20,
|
||||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["enableAltView"] = false
|
newObject?["enableAltView"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 4) {
|
if (oldSchemaVersion < 4) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||||
var indexCounter = 1
|
var indexCounter = 1
|
||||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
newObject?["index"] = indexCounter
|
newObject?["index"] = indexCounter
|
||||||
@@ -31,43 +29,48 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 5) {
|
if (oldSchemaVersion < 5) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["lockOrientation"] = "NONE"
|
newObject?["lockOrientation"] = "NONE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 6) {
|
if (oldSchemaVersion < 6) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["hapticFeedback"] = "LIGHT"
|
newObject?["hapticFeedback"] = "LIGHT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 15) {
|
if (oldSchemaVersion < 15) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||||
newObject?["languageCode"] = "en-us"
|
newObject?["languageCode"] = "en-us"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 16) {
|
if (oldSchemaVersion < 16) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["chapterTrack"] = false
|
newObject?["chapterTrack"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 17) {
|
if (oldSchemaVersion < 17) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["downloadUsingCellular"] = "ALWAYS"
|
newObject?["downloadUsingCellular"] = "ALWAYS"
|
||||||
newObject?["streamingUsingCellular"] = "ALWAYS"
|
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (oldSchemaVersion < 18) {
|
if (oldSchemaVersion < 18) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
newObject?["disableSleepTimerFadeOut"] = false
|
newObject?["disableSleepTimerFadeOut"] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (oldSchemaVersion < 20) {
|
||||||
|
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||||
|
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||||
|
newObject?["version"] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Realm.Configuration.defaultConfiguration = configuration
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
@@ -83,22 +86,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||||
logger.log("Audiobookself is now in the background")
|
AbsLogger.info(message: "Audiobookself is now in the background")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||||
logger.log("Audiobookself is now in the foreground")
|
AbsLogger.info(message: "Audiobookself is now in the foreground")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||||
logger.log("Audiobookself is now active")
|
AbsLogger.info(message: "Audiobookself is now active")
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||||
logger.log("Audiobookself is terminating")
|
AbsLogger.info(message: "Audiobookself is terminating")
|
||||||
}
|
}
|
||||||
|
|
||||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
"plugins": {
|
"plugins": {
|
||||||
"CapacitorHttp": {
|
"CapacitorHttp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "setSleepTimer", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
|
||||||
|
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
private var monitor: NWPathMonitor?
|
private var monitor: NWPathMonitor?
|
||||||
private let queue = DispatchQueue.global(qos: .background)
|
private let queue = DispatchQueue.global(qos: .background)
|
||||||
@@ -82,7 +80,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let startTimeOverride = call.getDouble("startTime")
|
let startTimeOverride = call.getDouble("startTime")
|
||||||
|
|
||||||
if libraryItemId == nil {
|
if libraryItemId == nil {
|
||||||
logger.error("provide library item id")
|
AbsLogger.error(message: "No id provided")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||||
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
let episode = item?.getPodcastEpisode(episodeId: episodeId)
|
||||||
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
guard let playbackSession = item?.getPlaybackSession(episode: episode) else {
|
||||||
logger.error("Failed to get local playback session")
|
AbsLogger.error(message: "Failed to get local playback session")
|
||||||
return call.resolve([:])
|
return call.resolve([:])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
try self.startPlaybackSession(playbackSession, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
call.resolve(try playbackSession.asDictionary())
|
call.resolve(try playbackSession.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("Failed to start session")
|
AbsLogger.error(message: "Failed to start session for local item: \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
@@ -119,7 +117,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
try self?.startPlaybackSession(session, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||||
call.resolve(try session.asDictionary())
|
call.resolve(try session.asDictionary())
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
self?.logger.error("Failed to start session")
|
AbsLogger.error(message: "Failed to start streaming session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve([:])
|
call.resolve([:])
|
||||||
}
|
}
|
||||||
@@ -127,19 +125,22 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops playback and closes session.
|
||||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||||
logger.log("Close playback")
|
AbsLogger.info(message: "Close playback")
|
||||||
|
|
||||||
PlayerHandler.stopPlayback()
|
PlayerHandler.stopPlayback()
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
||||||
call.resolve([
|
call.resolve([
|
||||||
"value": PlayerHandler.getCurrentTime() ?? 0,
|
"value": PlayerHandler.getCurrentTime() ?? 0,
|
||||||
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
|
"bufferedTime": PlayerHandler.getCurrentTime() ?? 0,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
@objc func setPlaybackSpeed(_ call: CAPPluginCall) {
|
||||||
let playbackRate = call.getFloat("value", 1.0)
|
let playbackRate = call.getFloat("value", 1.0)
|
||||||
let settings = PlayerSettings.main()
|
let settings = PlayerSettings.main()
|
||||||
@@ -152,7 +153,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
||||||
let chapterTrack = call.getBool("enabled", true)
|
let chapterTrack = call.getBool("enabled", true)
|
||||||
logger.log(String(chapterTrack))
|
AbsLogger.info(message: String(chapterTrack))
|
||||||
let settings = PlayerSettings.main()
|
let settings = PlayerSettings.main()
|
||||||
try? settings.update {
|
try? settings.update {
|
||||||
settings.chapterTrack = chapterTrack
|
settings.chapterTrack = chapterTrack
|
||||||
@@ -225,7 +226,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
let seconds = time / 1000
|
let seconds = time / 1000
|
||||||
|
|
||||||
logger.log("chapter time: \(isChapterTime)")
|
AbsLogger.info(message: "chapter time: \(isChapterTime)")
|
||||||
if isChapterTime {
|
if isChapterTime {
|
||||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||||
return call.resolve([ "success": true ])
|
return call.resolve([ "success": true ])
|
||||||
@@ -262,7 +263,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||||
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||||
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
guard let progressUpdate = try? localMediaProgress.asDictionary() else { return }
|
||||||
logger.log("Sending local progress back to the UI")
|
AbsLogger.info(message: "Sending local progress back to the UI")
|
||||||
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
||||||
let libraryItemId = session?.libraryItemId ?? ""
|
let libraryItemId = session?.libraryItemId ?? ""
|
||||||
let episodeId = session?.episodeId ?? nil
|
let episodeId = session?.episodeId ?? nil
|
||||||
logger.log("Forcing Transcode")
|
AbsLogger.info(message: "Forcing Transcode")
|
||||||
|
|
||||||
// If direct playing then fallback to transcode
|
// If direct playing then fallback to transcode
|
||||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
||||||
@@ -283,7 +284,7 @@ public class AbsAudioPlayer: CAPPlugin, CAPBridgedPlugin {
|
|||||||
self.sendPlaybackSession(session: try session.asDictionary())
|
self.sendPlaybackSession(session: try session.asDictionary())
|
||||||
self.sendMetadata()
|
self.sendMetadata()
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
self?.logger.error("Failed to start transcoded session")
|
AbsLogger.error(message: "Failed to start transcoded session")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
public let pluginMethods: [CAPPluginMethod] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise),
|
||||||
CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise),
|
CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise),
|
||||||
@@ -48,14 +50,25 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "updateLocalEbookProgress", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsDatabase")
|
private let secureStorage = SecureStorage()
|
||||||
|
|
||||||
|
// Used to notify the webview frontend that the token has been refreshed
|
||||||
|
static var tokenRefreshCallback: ((String, [String: Any]) -> Void)?
|
||||||
|
|
||||||
|
override public func load() {
|
||||||
|
AbsDatabase.tokenRefreshCallback = { [weak self] eventName, data in
|
||||||
|
self?.notifyListeners(eventName, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
var id = call.getString("id")
|
var id = call.getString("id")
|
||||||
let address = call.getString("address", "")
|
let address = call.getString("address", "")
|
||||||
|
let version = call.getString("version", "")
|
||||||
let userId = call.getString("userId", "")
|
let userId = call.getString("userId", "")
|
||||||
let username = call.getString("username", "")
|
let username = call.getString("username", "")
|
||||||
let token = call.getString("token", "")
|
let token = call.getString("token", "")
|
||||||
|
let refreshToken = call.getString("refreshToken", "") // Refresh only sent after login or refresh
|
||||||
|
|
||||||
let name = "\(address) (\(username))"
|
let name = "\(address) (\(username))"
|
||||||
|
|
||||||
@@ -63,11 +76,18 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
id = "\(address)@\(username)".toBase64()
|
id = "\(address)@\(username)".toBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (refreshToken != "") {
|
||||||
|
// Store refresh token securely if provided
|
||||||
|
let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken)
|
||||||
|
AbsLogger.info(message: "Refresh token secured = \(hasRefreshToken)")
|
||||||
|
}
|
||||||
|
|
||||||
let config = ServerConnectionConfig()
|
let config = ServerConnectionConfig()
|
||||||
config.id = id ?? ""
|
config.id = id ?? ""
|
||||||
config.index = 0
|
config.index = 0
|
||||||
config.name = name
|
config.name = name
|
||||||
config.address = address
|
config.address = address
|
||||||
|
config.version = version
|
||||||
config.userId = userId
|
config.userId = userId
|
||||||
config.username = username
|
config.username = username
|
||||||
config.token = token
|
config.token = token
|
||||||
@@ -76,12 +96,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let savedConfig = Store.serverConfig // Fetch the latest value
|
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||||
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||||
let id = call.getString("serverConnectionConfigId", "")
|
let id = call.getString("serverConnectionConfigId", "")
|
||||||
Database.shared.deleteServerConnectionConfig(id: id)
|
|
||||||
|
|
||||||
|
// Remove refresh token if it exists
|
||||||
|
_ = secureStorage.removeRefreshToken(serverConnectionConfigId: id)
|
||||||
|
|
||||||
|
Database.shared.deleteServerConnectionConfig(id: id)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func getRefreshToken(_ call: CAPPluginCall) {
|
||||||
|
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
|
||||||
|
|
||||||
|
let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
|
||||||
|
if let refreshToken = refreshToken {
|
||||||
|
call.resolve(["refreshToken": refreshToken])
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func clearRefreshToken(_ call: CAPPluginCall) {
|
||||||
|
let serverConnectionConfigId = call.getString("serverConnectionConfigId", "")
|
||||||
|
|
||||||
|
let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId)
|
||||||
|
call.resolve(["success": success])
|
||||||
|
}
|
||||||
|
|
||||||
@objc func logout(_ call: CAPPluginCall) {
|
@objc func logout(_ call: CAPPluginCall) {
|
||||||
Store.serverConfig = nil
|
Store.serverConfig = nil
|
||||||
call.resolve()
|
call.resolve()
|
||||||
@@ -104,15 +147,16 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let items = Database.shared.getLocalLibraryItems()
|
let items = Database.shared.getLocalLibraryItems()
|
||||||
call.resolve([ "value": try items.asDictionaryArray()])
|
call.resolve([ "value": try items.asDictionaryArray()])
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error reading local library items \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
|
let id = call.getString("id") ?? ""
|
||||||
do {
|
do {
|
||||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: id)
|
||||||
switch item {
|
switch item {
|
||||||
case .some(let foundItem):
|
case .some(let foundItem):
|
||||||
call.resolve(try foundItem.asDictionary())
|
call.resolve(try foundItem.asDictionary())
|
||||||
@@ -120,7 +164,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error reading local library item[\(id)] \(exception)")
|
||||||
debugPrint(exception)
|
debugPrint(exception)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
@@ -136,8 +180,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
} catch(let exception) {
|
} catch(let exception) {
|
||||||
logger.error("error while readling local library items")
|
AbsLogger.error(message: "error while readling local library items: \(exception)", error: exception)
|
||||||
debugPrint(exception)
|
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,8 +193,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
do {
|
do {
|
||||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error while loading local media progress")
|
AbsLogger.error(message: "Error while loading local media progress", error: error)
|
||||||
debugPrint(error)
|
|
||||||
call.resolve(["value": []])
|
call.resolve(["value": []])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +210,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||||
let isFirstSync = call.getBool("isFirstSync", false)
|
let isFirstSync = call.getBool("isFirstSync", false)
|
||||||
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))")
|
AbsLogger.info(message: "Starting syncLocalSessionsWithServer isFirstSync=\(isFirstSync)")
|
||||||
guard Store.serverConfig != nil else {
|
guard Store.serverConfig != nil else {
|
||||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
@@ -200,12 +242,13 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
AbsLogger.info(message: "Saving local media progress \(serverMediaProgress)")
|
||||||
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||||
|
|
||||||
call.resolve(try localMediaProgress.asDictionary())
|
call.resolve(try localMediaProgress.asDictionary())
|
||||||
} catch {
|
} catch {
|
||||||
call.reject("Failed to sync media progress")
|
call.reject("Failed to sync media progress")
|
||||||
|
AbsLogger.error(message: "Failed to sync: \(error)")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +263,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
|
AbsLogger.info(message: "\(localMediaProgressId): isFinished=\(isFinished)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||||
@@ -293,7 +336,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let ebookLocation = call.getString("ebookLocation", "")
|
let ebookLocation = call.getString("ebookLocation", "")
|
||||||
let ebookProgress = call.getDouble("ebookProgress", 0.0)
|
let ebookProgress = call.getDouble("ebookProgress", 0.0)
|
||||||
|
|
||||||
logger.log("updateLocalEbookProgress \(localLibraryItemId ?? "Unknown") | ebookLocation: \(ebookLocation) | ebookProgress: \(ebookProgress)")
|
AbsLogger.info(message: "\(localLibraryItemId ?? "Unknown"): ebookLocation=\(ebookLocation) ebookProgress=\(ebookProgress)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
|
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
static private let downloadsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsDownloader")
|
|
||||||
|
|
||||||
private lazy var session: URLSession = {
|
private lazy var session: URLSession = {
|
||||||
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
let config = URLSessionConfiguration.background(withIdentifier: "AbsDownloader")
|
||||||
let queue = OperationQueue()
|
let queue = OperationQueue()
|
||||||
@@ -31,31 +29,69 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
private var downloadItemProgress = [String: DownloadItem]()
|
private var downloadItemProgress = [String: DownloadItem]()
|
||||||
private var monitoringProgressTimer: Timer?
|
private var monitoringProgressTimer: Timer?
|
||||||
|
|
||||||
|
// Download queue management
|
||||||
|
private let downloadQueueLock = NSLock()
|
||||||
|
private var pendingDownloadTasks: [DownloadItemPartTask] = []
|
||||||
|
private var activeDownloadTasks: Set<String> = [] // Track active task IDs
|
||||||
|
private let maxConcurrentDownloads = 3
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Download Queue Management
|
||||||
|
|
||||||
|
private func startNextDownloadInQueue() {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
defer { downloadQueueLock.unlock() }
|
||||||
|
|
||||||
|
// Start downloads up to the max concurrent limit
|
||||||
|
while activeDownloadTasks.count < maxConcurrentDownloads && !pendingDownloadTasks.isEmpty {
|
||||||
|
let nextTask = pendingDownloadTasks.removeFirst()
|
||||||
|
activeDownloadTasks.insert(nextTask.partId)
|
||||||
|
AbsLogger.info(message: "Starting download for \(nextTask.filename) (\(activeDownloadTasks.count)/\(maxConcurrentDownloads) active, \(pendingDownloadTasks.count) pending)")
|
||||||
|
nextTask.task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markDownloadTaskCompleted(_ taskId: String) {
|
||||||
|
downloadQueueLock.lock()
|
||||||
|
activeDownloadTasks.remove(taskId)
|
||||||
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
// Try to start the next download
|
||||||
|
startNextDownloadInQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Progress handling
|
// MARK: - Progress handling
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||||
let realm = try Realm()
|
let realm = try Realm()
|
||||||
|
let partId = downloadItemPart.id
|
||||||
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize
|
liveDownloadItemPart.bytesDownloaded = liveDownloadItemPart.fileSize
|
||||||
downloadItemPart.progress = 100
|
liveDownloadItemPart.progress = 100
|
||||||
downloadItemPart.completed = true
|
liveDownloadItemPart.completed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Move the downloaded file into place
|
// Move the downloaded file into place
|
||||||
guard let destinationUrl = downloadItemPart.destinationURL else {
|
guard let destinationUrl = liveDownloadItemPart.destinationURL else {
|
||||||
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
throw LibraryItemDownloadError.downloadItemPartDestinationUrlNotDefined
|
||||||
}
|
}
|
||||||
try? FileManager.default.removeItem(at: destinationUrl)
|
try? FileManager.default.removeItem(at: destinationUrl)
|
||||||
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.moved = true
|
liveDownloadItemPart.moved = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
try realm.write {
|
try realm.write {
|
||||||
downloadItemPart.failed = true
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -65,13 +101,26 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
handleDownloadTaskUpdate(downloadTask: task) { downloadItem, downloadItemPart in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.completed = true
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.failed = true
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.completed = true
|
||||||
|
liveDownloadItemPart.failed = true
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this task as completed and start the next download in queue
|
||||||
|
if let taskId = task.taskDescription {
|
||||||
|
markDownloadTaskCompleted(taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
@@ -81,9 +130,17 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
// Only update the progress if we received accurate progress data
|
// Only update the progress if we received accurate progress data
|
||||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||||
try Realm().write {
|
let realm = try Realm()
|
||||||
downloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
let partId = downloadItemPart.id
|
||||||
downloadItemPart.progress = percentDownloaded
|
|
||||||
|
// Get fresh reference to the object in this realm
|
||||||
|
guard let liveDownloadItemPart = realm.object(ofType: DownloadItemPart.self, forPrimaryKey: partId) else {
|
||||||
|
throw LibraryItemDownloadError.downloadItemPartNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
try realm.write {
|
||||||
|
liveDownloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||||
|
liveDownloadItemPart.progress = percentDownloaded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +161,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
private func handleDownloadTaskUpdate(downloadTask: URLSessionTask, progressHandler: DownloadProgressHandler) {
|
||||||
do {
|
do {
|
||||||
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
guard let downloadItemPartId = downloadTask.taskDescription else { throw LibraryItemDownloadError.noTaskDescription }
|
||||||
logger.log("Received download update for \(downloadItemPartId)")
|
AbsLogger.info(message: "Received download update for \(downloadItemPartId)")
|
||||||
|
|
||||||
// Find the download item
|
// Find the download item
|
||||||
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
let downloadItem = Database.shared.getDownloadItem(downloadItemPartId: downloadItemPartId)
|
||||||
@@ -119,7 +176,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
try progressHandler(downloadItem, part)
|
try progressHandler(downloadItem, part)
|
||||||
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error while processing progress")
|
AbsLogger.error(message: "Error while processing progress")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +187,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
}
|
}
|
||||||
self.notifyDownloadProgress()
|
self.notifyDownloadProgress()
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("DownloadItemError")
|
AbsLogger.error(message: "DownloadItemError")
|
||||||
debugPrint(error)
|
debugPrint(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,18 +195,18 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
// We want to handle updating the UI in the background and throttled so we don't overload the UI with progress updates
|
||||||
private func notifyDownloadProgress() {
|
private func notifyDownloadProgress() {
|
||||||
if self.monitoringProgressTimer?.isValid ?? false {
|
if self.monitoringProgressTimer?.isValid ?? false {
|
||||||
logger.log("Already monitoring progress, no need to start timer again")
|
AbsLogger.info(message: "Already monitoring progress, no need to start timer again")
|
||||||
} else {
|
} else {
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
|
self.monitoringProgressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true, block: { [unowned self] t in
|
||||||
self.logger.log("Starting monitoring download progress...")
|
AbsLogger.info(message: "Starting monitoring download progress...")
|
||||||
|
|
||||||
// Fetch active downloads in a thread-safe way
|
// Fetch active downloads in a thread-safe way
|
||||||
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
func fetchActiveDownloads() -> [String: DownloadItem]? {
|
||||||
self.progressStatusQueue.sync {
|
self.progressStatusQueue.sync {
|
||||||
let activeDownloads = self.downloadItemProgress
|
let activeDownloads = self.downloadItemProgress
|
||||||
if activeDownloads.isEmpty {
|
if activeDownloads.isEmpty {
|
||||||
logger.log("Finishing monitoring download progress...")
|
AbsLogger.info(message: "Finishing monitoring download progress...")
|
||||||
t.invalidate()
|
t.invalidate()
|
||||||
}
|
}
|
||||||
return activeDownloads
|
return activeDownloads
|
||||||
@@ -184,7 +241,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
if ( downloadItem.didDownloadSuccessfully() ) {
|
if ( downloadItem.didDownloadSuccessfully() ) {
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in
|
ApiClient.getLibraryItemWithProgress(libraryItemId: downloadItem.libraryItemId!, episodeId: downloadItem.episodeId) { [weak self] libraryItem in
|
||||||
guard let libraryItem = libraryItem else { self?.logger.error("LibraryItem not found"); return }
|
guard let libraryItem = libraryItem else { AbsLogger.error(message: "LibraryItem not found"); return }
|
||||||
let localDirectory = libraryItem.id
|
let localDirectory = libraryItem.id
|
||||||
var coverFile: String?
|
var coverFile: String?
|
||||||
|
|
||||||
@@ -231,12 +288,12 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
var episodeId = call.getString("episodeId")
|
var episodeId = call.getString("episodeId")
|
||||||
if ( episodeId == "null" ) { episodeId = nil }
|
if ( episodeId == "null" ) { episodeId = nil }
|
||||||
|
|
||||||
logger.log("Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
AbsLogger.info(message: "Download library item \(libraryItemId ?? "N/A") / episode \(episodeId ?? "N/A")")
|
||||||
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
guard let libraryItemId = libraryItemId else { return call.resolve(["error": "libraryItemId not specified"]) }
|
||||||
|
|
||||||
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
|
ApiClient.getLibraryItemWithProgress(libraryItemId: libraryItemId, episodeId: episodeId) { [weak self] libraryItem in
|
||||||
if let libraryItem = libraryItem {
|
if let libraryItem = libraryItem {
|
||||||
self?.logger.log("Got library item from server \(libraryItem.id)")
|
AbsLogger.info(message: "Got library item from server \(libraryItem.id)")
|
||||||
do {
|
do {
|
||||||
if let episodeId = episodeId {
|
if let episodeId = episodeId {
|
||||||
// Download a podcast episode
|
// Download a podcast episode
|
||||||
@@ -310,14 +367,19 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Persist in the database before status start coming in
|
// Persist in the database before status start coming in
|
||||||
try Database.shared.saveDownloadItem(downloadItem)
|
try Database.shared.saveDownloadItem(downloadItem)
|
||||||
|
|
||||||
// Start all the downloads
|
// Add all tasks to the download queue
|
||||||
for task in tasks {
|
downloadQueueLock.lock()
|
||||||
task.task.resume()
|
pendingDownloadTasks.append(contentsOf: tasks)
|
||||||
}
|
downloadQueueLock.unlock()
|
||||||
|
|
||||||
|
AbsLogger.info(message: "Added \(tasks.count) tasks to download queue. Starting downloads...")
|
||||||
|
|
||||||
|
// Start downloading (up to maxConcurrentDownloads at a time)
|
||||||
|
startNextDownloadInQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||||
logger.log("TRACK \(track.contentUrl!)")
|
AbsLogger.info(message: "TRACK \(track.contentUrl!)")
|
||||||
|
|
||||||
// If we don't name metadata, then we can't proceed
|
// If we don't name metadata, then we can't proceed
|
||||||
guard let filename = track.metadata?.filename else {
|
guard let filename = track.metadata?.filename else {
|
||||||
@@ -334,7 +396,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
private func startLibraryItemEbookDownload(downloadItemId: String, item: LibraryItem, ebookFile: EBookFile) throws -> DownloadItemPartTask {
|
||||||
@@ -349,7 +411,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
||||||
@@ -369,7 +431,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
// Store the id on the task so the download item can be pulled from the database later
|
// Store the id on the task so the download item can be pulled from the database later
|
||||||
task.taskDescription = part.id
|
task.taskDescription = part.id
|
||||||
|
|
||||||
return DownloadItemPartTask(part: part, task: task)
|
return DownloadItemPartTask(part: part, task: task, partId: part.id, filename: filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
private func urlForTrack(item: LibraryItem, track: AudioTrack) -> URL {
|
||||||
@@ -393,10 +455,10 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
private func createLibraryItemFileDirectory(item: LibraryItem) throws -> String {
|
||||||
let itemDirectory = item.id
|
let itemDirectory = item.id
|
||||||
logger.log("ITEM DIR \(itemDirectory)")
|
AbsLogger.info(message: "ITEM DIR \(itemDirectory)")
|
||||||
|
|
||||||
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
guard AbsDownloader.itemDownloadFolder(path: itemDirectory) != nil else {
|
||||||
logger.error("Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(itemDirectory)")
|
||||||
throw LibraryItemDownloadError.failedDirectory
|
throw LibraryItemDownloadError.failedDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +480,7 @@ public class AbsDownloader: CAPPlugin, CAPBridgedPlugin, URLSessionDownloadDeleg
|
|||||||
|
|
||||||
return itemFolder
|
return itemFolder
|
||||||
} catch {
|
} catch {
|
||||||
AppLogger().error("Failed to CREATE LI DIRECTORY \(error)")
|
AbsLogger.error(message: "Failed to CREATE LI DIRECTORY \(error)", error: error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +495,8 @@ typealias DownloadProgressHandler = (_ downloadItem: DownloadItem, _ downloadIte
|
|||||||
struct DownloadItemPartTask {
|
struct DownloadItemPartTask {
|
||||||
let part: DownloadItemPart
|
let part: DownloadItemPart
|
||||||
let task: URLSessionDownloadTask
|
let task: URLSessionDownloadTask
|
||||||
|
let partId: String // Cache the ID to avoid cross-thread Realm access
|
||||||
|
let filename: String // Cache the filename to avoid cross-thread Realm access
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LibraryItemDownloadError: String, Error {
|
enum LibraryItemDownloadError: String, Error {
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "deleteTrackFromItem", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsFileSystem")
|
|
||||||
|
|
||||||
@objc func selectFolder(_ call: CAPPluginCall) {
|
@objc func selectFolder(_ call: CAPPluginCall) {
|
||||||
let mediaType = call.getString("mediaType")
|
let mediaType = call.getString("mediaType")
|
||||||
|
|
||||||
logger.log("Select Folder for media type \(mediaType ?? "UNSET")")
|
AbsLogger.info(message: "Select Folder for media type \(mediaType ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -36,7 +34,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
@objc func checkFolderPermission(_ call: CAPPluginCall) {
|
||||||
let folderUrl = call.getString("folderUrl")
|
let folderUrl = call.getString("folderUrl")
|
||||||
|
|
||||||
logger.log("checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
AbsLogger.info(message: "checkFolderPermission for folder \(folderUrl ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -45,7 +43,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
logger.log("scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
AbsLogger.info(message: "scanFolder \(folderId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -53,7 +51,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func removeFolder(_ call: CAPPluginCall) {
|
@objc func removeFolder(_ call: CAPPluginCall) {
|
||||||
let folderId = call.getString("folderId")
|
let folderId = call.getString("folderId")
|
||||||
|
|
||||||
logger.log("removeFolder \(folderId ?? "UNSET")")
|
AbsLogger.info(message: "removeFolder \(folderId ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -61,7 +59,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
@objc func removeLocalLibraryItem(_ call: CAPPluginCall) {
|
||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
|
|
||||||
logger.log("removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
AbsLogger.info(message: "removeLocalLibraryItem \(localLibraryItemId ?? "UNSET")")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||||
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
let forceAudioProbe = call.getBool("forceAudioProbe", false)
|
||||||
|
|
||||||
logger.log("scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
AbsLogger.info(message: "scanLocalLibraryItem \(localLibraryItemId ?? "UNSET") | Force Probe = \(forceAudioProbe)")
|
||||||
|
|
||||||
call.unavailable("Not available on iOS")
|
call.unavailable("Not available on iOS")
|
||||||
}
|
}
|
||||||
@@ -79,7 +77,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("id")
|
let localLibraryItemId = call.getString("id")
|
||||||
let contentUrl = call.getString("contentUrl")
|
let contentUrl = call.getString("contentUrl")
|
||||||
|
|
||||||
logger.log("deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
AbsLogger.info(message: "deleteItem \(localLibraryItemId ?? "UNSET") url \(contentUrl ?? "UNSET")")
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
do {
|
do {
|
||||||
@@ -89,7 +87,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
success = true
|
success = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +98,7 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
let localLibraryItemId = call.getString("id")
|
let localLibraryItemId = call.getString("id")
|
||||||
let trackLocalFileId = call.getString("trackLocalFileId")
|
let trackLocalFileId = call.getString("trackLocalFileId")
|
||||||
|
|
||||||
logger.log("deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
AbsLogger.info(message: "deleteTrackFromItem \(localLibraryItemId ?? "UNSET") track file \(trackLocalFileId ?? "UNSET")")
|
||||||
|
|
||||||
var success = false
|
var success = false
|
||||||
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
if let localLibraryItemId = localLibraryItemId, let trackLocalFileId = trackLocalFileId, let item = Database.shared.getLocalLibraryItem(localLibraryItemId: localLibraryItemId) {
|
||||||
@@ -120,12 +118,12 @@ public class AbsFileSystem: CAPPlugin, CAPBridgedPlugin {
|
|||||||
success = true
|
success = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to delete \(error)")
|
AbsLogger.error(message: "Failed to delete \(error)")
|
||||||
success = false
|
success = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
|
|
||||||
|
enum AbsLogLevel {
|
||||||
|
case info
|
||||||
|
case warn
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileName(file: String = #file) -> String {
|
||||||
|
return (file as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
@objc(AbsLogger)
|
@objc(AbsLogger)
|
||||||
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
||||||
public var identifier = "AbsLoggerPlugin"
|
public var identifier = "AbsLoggerPlugin"
|
||||||
@@ -19,29 +29,96 @@ public class AbsLogger: CAPPlugin, CAPBridgedPlugin {
|
|||||||
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "clearLogs", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
private let logger = AppLogger(category: "AbsLogger")
|
private static let shared: AbsLogger = {
|
||||||
|
AbsLogger()
|
||||||
|
}()
|
||||||
|
|
||||||
|
public static func info(_ tag: String = #file, message: String) {
|
||||||
|
try? shared.info(tag: fileName(file: tag), message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func error(_ tag: String = #file, message: String, error: Error? = nil) {
|
||||||
|
try? shared.error(tag: fileName(file: tag), message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let loggers: Dictionary<String, AppLogger> = [:]
|
||||||
|
|
||||||
|
private func _logger(_ tag: String) -> AppLogger {
|
||||||
|
return loggers[tag, default: AppLogger(category: tag)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveLogEntry(_ level: AbsLogLevel, tag: String, message: String) throws {
|
||||||
|
let entry = LogEntry()
|
||||||
|
entry.tag = tag
|
||||||
|
entry.message = message
|
||||||
|
entry.level = "\(level)"
|
||||||
|
entry.timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
try Database.shared.saveLog(entry)
|
||||||
|
|
||||||
|
self.notifyListeners("onLog", data: [
|
||||||
|
"id": entry.id,
|
||||||
|
"tag": entry.tag,
|
||||||
|
"message": entry.message,
|
||||||
|
"level": entry.level,
|
||||||
|
"timestamp": entry.timestamp
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func info(tag: String = "\(#file)", message: String) throws {
|
||||||
|
_logger(tag).log("[\(tag)] \(message)")
|
||||||
|
try saveLogEntry(.info, tag: tag, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(tag: String = "\(#file)", message: String, error: Error? = nil) throws {
|
||||||
|
_logger(tag).error("[\(tag)] \(message)")
|
||||||
|
if let error = error { _logger(tag).error(error) }
|
||||||
|
try saveLogEntry(.error, tag: tag, message: message)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func info(_ call: CAPPluginCall) {
|
@objc func info(_ call: CAPPluginCall) {
|
||||||
let message = call.getString("message") ?? ""
|
let message = call.getString("message") ?? ""
|
||||||
let tag = call.getString("tag") ?? ""
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
logger.log("[\(tag)] \(message)")
|
do {
|
||||||
|
try info(tag: tag, message: message)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to log \(message)", "101", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func error(_ call: CAPPluginCall) {
|
@objc func error(_ call: CAPPluginCall) {
|
||||||
let message = call.getString("message") ?? ""
|
let message = call.getString("message") ?? ""
|
||||||
let tag = call.getString("tag") ?? ""
|
let tag = call.getString("tag") ?? ""
|
||||||
|
|
||||||
logger.error("[\(tag)] \(message)")
|
do {
|
||||||
|
try error(tag: tag, message: message)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to log \(message)", "101", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func getAllLogs(_ call: CAPPluginCall) {
|
@objc func getAllLogs(_ call: CAPPluginCall) {
|
||||||
call.unimplemented("Not implemented on iOS")
|
do {
|
||||||
|
let logs = Database.shared.getAllLogs()
|
||||||
|
call.resolve([ "value": try logs.asDictionaryArray()])
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to get logs", "100", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func clearLogs(_ call: CAPPluginCall) {
|
@objc func clearLogs(_ call: CAPPluginCall) {
|
||||||
call.unimplemented("Not implemented on iOS")
|
do {
|
||||||
|
try Database.shared.clearLogs()
|
||||||
|
call.resolve()
|
||||||
|
} catch {
|
||||||
|
call.reject("Failed to clear logs", "100", error)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||