Compare commits
779 commits
v0.9.76-be
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
48a299de45 | ||
|
719e517dda | ||
|
361c55c5ac | ||
|
b157fff229 | ||
|
e23e6417ee | ||
|
ad77209832 | ||
|
4ceb427d9c | ||
|
16a26130f2 | ||
|
e7773c9e88 | ||
|
1b38b92177 | ||
|
ef180f08b0 | ||
|
6de45a3b88 | ||
|
ceb9537b73 | ||
|
976bf73388 | ||
|
6c7f2d96f3 | ||
|
ae2fc1fcfb | ||
|
3aad7e9e07 | ||
|
e43f1e75c8 | ||
|
bc5505e12d | ||
|
19a2703ea4 | ||
|
8a8f7d4f27 | ||
|
e350b4970f | ||
|
4be28bc579 | ||
|
13855a8682 | ||
|
4be1598eca | ||
|
7aebcd92c3 | ||
|
bd8668f0bf | ||
|
c6a7c6fec2 | ||
|
e367fa8a86 | ||
|
ecdbabfa17 | ||
|
796189ad48 | ||
|
76d1f2b29b | ||
|
73599815ba | ||
|
66372e9743 | ||
|
0d7f92129b | ||
|
9da49d6116 | ||
|
7eeb9095e8 | ||
|
58281bb5ce | ||
|
f24dd3b289 | ||
|
51f2e5e7ec | ||
|
a937800ef0 | ||
|
c34d14edb5 | ||
|
8722e91c0f | ||
|
8038a8ac66 | ||
|
69a54c5fa9 | ||
|
aa508887f3 | ||
|
a53a96ecf5 | ||
|
82cddde15f | ||
|
def47fd5e2 | ||
|
04c70f54aa | ||
|
03e2feddb3 | ||
|
fc69909dd0 | ||
|
15fa42986a | ||
|
d8b4cf98dd | ||
|
ce62f3b273 | ||
|
8be2e3af0c | ||
|
e44c574068 | ||
|
c31aa17ce2 | ||
|
decac596d0 | ||
|
2d9604aa07 | ||
|
79c4ffa6c5 | ||
|
571b3a2c86 | ||
|
fa31fba586 | ||
|
2f3b261392 | ||
|
afd3cd6716 | ||
|
be99ef87ad | ||
|
572fc8545f | ||
|
239a943172 | ||
|
87614bc78a | ||
|
224d75fac5 | ||
|
f4e0a6121f | ||
|
80ee88b488 | ||
|
beb654825f | ||
|
79d8ccbf52 | ||
|
b48c74eca0 | ||
|
19e3dee706 | ||
|
e1be86c3ce | ||
|
11f7eddd7b | ||
|
7f28d2ea70 | ||
|
4607a8e274 | ||
|
26c559104a | ||
|
e15e884e65 | ||
|
b29401909f | ||
|
beb5e1a56c | ||
|
be08efeca3 | ||
|
4534ffaead | ||
|
d35dd2df1a | ||
|
2f3a9a5d96 | ||
|
fab94cd363 | ||
|
b06274866d | ||
|
5766c49f61 | ||
|
bc927d4c35 | ||
|
5804c54656 | ||
|
52f86cbce9 | ||
|
f6e2e4010f | ||
|
44613e12f1 | ||
|
b99e0b112b | ||
|
80b565c23f | ||
|
bee88c43e5 | ||
|
467fedbfe7 | ||
|
ba4e9ab7e3 | ||
|
1b4fa5e44a | ||
|
e92c9b2990 | ||
|
f23f8e9ade | ||
|
dc00aeece4 | ||
|
d8cdb7073e | ||
|
4fdea9b0ec | ||
|
3ded7687f7 | ||
|
42119425af | ||
|
93c7e1c44a | ||
|
381fd84839 | ||
|
67bab72783 | ||
|
b9c084a442 | ||
|
a77f345596 | ||
|
6a2f487ed5 | ||
|
616cf9029d | ||
|
3f21b4172b | ||
|
62f6a11522 | ||
|
ae1dce455d | ||
|
7b5dc52620 | ||
|
0bf50de54d | ||
|
1eec470902 | ||
|
867dc69479 | ||
|
9792f4ebc1 | ||
|
a44fc41caa | ||
|
1927dbf0ad | ||
|
7052674f32 | ||
|
9a487f3bb6 | ||
|
d6242c350d | ||
|
4cc4aceb41 | ||
|
dd8f4d6201 | ||
|
f44dcdf124 | ||
|
65e8f2317e | ||
|
1c4f43db7c | ||
|
24b9df6fbd | ||
|
c46891ecb0 | ||
|
e723601f6b | ||
|
77545df174 | ||
|
7e136b17a5 | ||
|
a970a06f57 | ||
|
95dd78d4aa | ||
|
eeaf33be20 | ||
|
7ed0cd26fe | ||
|
d26403c800 | ||
|
5ed6e3a6a3 | ||
|
7e83405370 | ||
|
a5de31da2b | ||
|
39c06005ed | ||
|
9479160044 | ||
|
46ec7369e5 | ||
|
424e7d742b | ||
|
44036399b9 | ||
|
e4394da467 | ||
|
b837ada3f8 | ||
|
a623de89aa | ||
|
2768407907 | ||
|
64ed35d2b1 | ||
|
b92f4dfa02 | ||
|
e4f02b7a51 | ||
|
24c3f45018 | ||
|
fcf55eb581 | ||
|
9ade7add32 | ||
|
d16dfcb4b4 | ||
|
37c58a5d32 | ||
|
e6cdde4f70 | ||
|
93c0adecbd | ||
|
33d44a5181 | ||
|
80e6fa7e7a | ||
|
b8eed48112 | ||
|
8d563dcfed | ||
|
1cf36e5549 | ||
|
1357a0628f | ||
|
c4fe0680f3 | ||
|
16694aa932 | ||
|
ea59ad2953 | ||
|
cf2f684e80 | ||
|
01522eda43 | ||
|
a200ee1310 | ||
|
579a07baa2 | ||
|
a82dd88b1f | ||
|
36ba98920a | ||
|
47cc0c34eb | ||
|
53eaca8724 | ||
|
8a2814b391 | ||
|
345c8ab217 | ||
|
7c010895e0 | ||
|
e716cfd19b | ||
|
461a6c8be5 | ||
|
3360f1a3f6 | ||
|
8b939e8a02 | ||
|
3a8998081c | ||
|
9b986a7b15 | ||
|
69ab395798 | ||
|
7aa3c33348 | ||
|
734328dd1f | ||
|
7d6c30e733 | ||
|
3e682dfe50 | ||
|
50efda3858 | ||
|
ca1b9e2bdb | ||
|
aa3a91b9d9 | ||
|
7117608445 | ||
|
6de640f53f | ||
|
75627ea40f | ||
|
0fa04d7d9a | ||
|
1e76ebe075 | ||
|
965da2fee3 | ||
|
b03f59ace3 | ||
|
79c7244b24 | ||
|
0843be3f4e | ||
|
6911f368e5 | ||
|
42167ea738 | ||
|
1c57d3506a | ||
|
8adc6fae4c | ||
|
e9746577ba | ||
|
9f68730622 | ||
|
f019b67b0c | ||
|
0f650c0572 | ||
|
dfc77ea0d0 | ||
|
67f514524f | ||
|
d97c6a0872 | ||
|
e7ad62760f | ||
|
fd34ea8124 | ||
|
5db94bf5b8 | ||
|
796d6d79d4 | ||
|
03aafafe1c | ||
|
3f303abc19 | ||
|
85d6958025 | ||
|
669bd7827b | ||
|
2b48f0c6a9 | ||
|
2c44d38906 | ||
|
750726ff6a | ||
|
dafab492fe | ||
|
6419c8dc3a | ||
|
3bb5ce5924 | ||
|
882c2749ab | ||
|
26b0fae0fb | ||
|
fe921fd5b1 | ||
|
88e1877742 | ||
|
74758c7762 | ||
|
2000534e37 | ||
|
390388fe83 | ||
|
b9e3ccd0c1 | ||
|
23e0a44e54 | ||
|
e788f8767a | ||
|
d09e88f138 | ||
|
6e1eee48f0 | ||
|
99c8949861 | ||
|
ce44ee8514 | ||
|
490b45d476 | ||
|
e403304e46 | ||
|
2bc00bb7f7 | ||
|
2785b1d09a | ||
|
1d703f8c87 | ||
|
93748a917f | ||
|
46d5e1d96c | ||
|
1e791f9601 | ||
|
ea2e8c7cc9 | ||
|
9ca160f1da | ||
|
1b0843d12e | ||
|
b1c4ceb40a | ||
|
ff626a3609 | ||
|
0862aecfc9 | ||
|
e119672336 | ||
|
71f6f53111 | ||
|
8cf757c080 | ||
|
6e0f67b19c | ||
|
fa2400871a | ||
|
7ef3f1895e | ||
|
637911e6b7 | ||
|
1b0d7c2463 | ||
|
dfb793247c | ||
|
a730ca38f2 | ||
|
e59a955e6b | ||
|
0fbe5f6c86 | ||
|
312b04acc9 | ||
|
8c20b7985b | ||
|
c10a82842e | ||
|
7a608bb2b2 | ||
|
a8493b2778 | ||
|
02a6dd71d4 | ||
|
bb5e1e6d71 | ||
|
bfbb04dc93 | ||
|
8f1b92add5 | ||
|
cab98eff3d | ||
|
4877d41ff2 | ||
|
01c5c03653 | ||
|
be31f265b8 | ||
|
6ef9721b7d | ||
|
13a50c990b | ||
|
67e2f2366d | ||
|
0947c2fe6f | ||
|
7c8d5658f3 | ||
|
66cf4639e6 | ||
|
8b370da5b7 | ||
|
a6d49fc926 | ||
|
8aedd1cd95 | ||
|
647bd8193b | ||
|
b6349fb3a7 | ||
|
675c10f7f7 | ||
|
5ab5609397 | ||
|
6de4bc14af | ||
|
8579536eff | ||
|
36ec19e25e | ||
|
b6b54e04b8 | ||
|
5e4ac06493 | ||
|
1f05e18ce4 | ||
|
cd059542c6 | ||
|
b207c18abe | ||
|
6b7d3775e4 | ||
|
7e1d942e0a | ||
|
5cb49daaba | ||
|
4e132dcc28 | ||
|
288ef8f368 | ||
|
66206adbe0 | ||
|
c6529ed8a3 | ||
|
6ddb44afb6 | ||
|
7f84bbe43c | ||
|
51b8eece7e | ||
|
c891c227a6 | ||
|
03457ef138 | ||
|
36fb117f89 | ||
|
605b52df0e | ||
|
45d3a15c68 | ||
|
78d7ba69df | ||
|
c72f7cddc8 | ||
|
e7c3242765 | ||
|
35d60ecaa0 | ||
|
bcf40c0b76 | ||
|
922601eab7 | ||
|
bc4267ea1d | ||
|
f65d3213fe | ||
|
bf2107c01b | ||
|
33c738873f | ||
|
5bf724b2a2 | ||
|
769ce0ade9 | ||
|
6f01eafd30 | ||
|
5ec27932b0 | ||
|
d79ff6e6d5 | ||
|
b0e7d3af2c | ||
|
f6966efff7 | ||
|
79e71a6978 | ||
|
5beed3e824 | ||
|
c6ea9daa3a | ||
|
95440951e5 | ||
|
7a45da2293 | ||
|
8ae8390339 | ||
|
27bc1f4a66 | ||
|
b887052d65 | ||
|
90bd2fc54c | ||
|
e433f7173f | ||
|
a1ce605719 | ||
|
665cbddcf5 | ||
|
6c0a39099f | ||
|
b2576d0b35 | ||
|
7107ea4aff | ||
|
2c43a71850 | ||
|
16b93bc490 | ||
|
d56a77acdf | ||
|
5da1dd1756 | ||
|
66e57e8d8f | ||
|
db1d7ae549 | ||
|
3d85c4085b | ||
|
da23b2cc51 | ||
|
38926a5b4f | ||
|
b39003d519 | ||
|
c79ea6ffd2 | ||
|
a7f1ead7fe | ||
|
3c56087432 | ||
|
1e98dea091 | ||
|
d239d06c3e | ||
|
339393e904 | ||
|
40cfb2593c | ||
|
346b418908 | ||
|
03c2850c38 | ||
|
1c29a08679 | ||
|
db89ac7743 | ||
|
412333cfc4 | ||
|
033b0b6ebf | ||
|
ab02ce5601 | ||
|
d46777595d | ||
|
669ced862e | ||
|
ba66cc02b7 | ||
|
1e840250b9 | ||
|
2b615a51fb | ||
|
f0c92be5f2 | ||
|
20206d6e14 | ||
|
7c6e098014 | ||
|
1e0f1f329f | ||
|
b2ebeafed5 | ||
|
9492975a74 | ||
|
e194df455b | ||
|
1e01139028 | ||
|
1141c6f7a5 | ||
|
5a1951b495 | ||
|
3b3f94124a | ||
|
bb56a55143 | ||
|
853a12b0dd | ||
|
840641681e | ||
|
022951f406 | ||
|
b6c9df62eb | ||
|
27a14a6e3b | ||
|
45e3620634 | ||
|
d770d6aac7 | ||
|
37d60252fd | ||
|
1cdcb48c1f | ||
|
f9920a0b6b | ||
|
00f524db16 | ||
|
51401e333f | ||
|
7692c29a81 | ||
|
d7c755235e | ||
|
8589efdf6e | ||
|
02b83f02d6 | ||
|
e4e7679414 | ||
|
abdf51d045 | ||
|
5fd21c8393 | ||
|
e7e03697d6 | ||
|
9e7a76bd97 | ||
|
d4090d15be | ||
|
973dca83a2 | ||
|
c7f51e815c | ||
|
1c302a7ac1 | ||
|
5d29efe1d5 | ||
|
b066df4efc | ||
|
fa6b71afae | ||
|
3d0c064d41 | ||
|
50755ead18 | ||
|
79f7fa32ab | ||
|
703ab710e9 | ||
|
bc782ba9ee | ||
|
f2eef64d84 | ||
|
abb3a7a3a9 | ||
|
ef61e020b6 | ||
|
66c87f37e6 | ||
|
56caab19f1 | ||
|
27eb5f72f7 | ||
|
23a80a60b9 | ||
|
0520cbd538 | ||
|
c79ecbb92e | ||
|
08651d28ef | ||
|
3060d186a1 | ||
|
0509d7105e | ||
|
b6ab7dc8a7 | ||
|
822ca65349 | ||
|
4b4a2b46c1 | ||
|
13b020732f | ||
|
fb9ca7f5f3 | ||
|
e21d37b20f | ||
|
161614f6c9 | ||
|
16472e1de8 | ||
|
d81e47204c | ||
|
7ec93cd91e | ||
|
af516991a9 | ||
|
2cac88cf7c | ||
|
1546a85195 | ||
|
9eba3e4dc4 | ||
|
b568e2ecf1 | ||
|
9dce119530 | ||
|
24f9b134cd | ||
|
c3e6bdd73d | ||
|
80e11c1492 | ||
|
bdebb4972e | ||
|
9b5f9e00d9 | ||
|
57167b8a10 | ||
|
98889a0baf | ||
|
0c535bb2d8 | ||
|
f4c34a3102 | ||
|
1f4b4d30ee | ||
|
1b1dc91c72 | ||
|
5a6a76bd63 | ||
|
e5263f7719 | ||
|
267229f929 | ||
|
79ee8b09d6 | ||
|
7824693254 | ||
|
0b2319fbaa | ||
|
dea3844090 | ||
|
be2c5759f2 | ||
|
c8b14d7822 | ||
|
1b54016035 | ||
|
50fe370585 | ||
|
e423ad168a | ||
|
1d7670830c | ||
|
d05dbfd5fb | ||
|
7e3f1f1ffa | ||
|
882ed1871a | ||
|
7c5c7d5632 | ||
|
240c0da577 | ||
|
7472ef01a8 | ||
|
9907095a2d | ||
|
658a527d93 | ||
|
3d25f3dd62 | ||
|
6290e983e9 | ||
|
10ae92ec41 | ||
|
2c25286155 | ||
|
656ede98b6 | ||
|
5fb0118f60 | ||
|
27e47b2b4b | ||
|
f02e3358aa | ||
|
7caae3171c | ||
|
0cded599b1 | ||
|
4e48240158 | ||
|
b488e24798 | ||
|
7e7d87c147 | ||
|
8b6389cf3a | ||
|
8a2725b48b | ||
|
a7e8fe12f4 | ||
|
67a5473e27 | ||
|
e16fba8eab | ||
|
cdfbafc789 | ||
|
c318f5d57b | ||
|
556ab85b0d | ||
|
20018aed27 | ||
|
e2facc87f0 | ||
|
07e951039c | ||
|
386d9ebc52 | ||
|
d8e058a416 | ||
|
f5d9c6076c | ||
|
e870855602 | ||
|
a3a6b4618b | ||
|
3ecae3e16b | ||
|
111e8d38dc | ||
|
d5fa36b11a | ||
|
a35c94cf03 | ||
|
b2eff46c38 | ||
|
07c0187423 | ||
|
0074078539 | ||
|
d1641ac0e8 | ||
|
13d3489cde | ||
|
30efe6bd0a | ||
|
5d67c71791 | ||
|
c8d9887070 | ||
|
8268592e8e | ||
|
3b63c5c3f9 | ||
|
d3918df4e3 | ||
|
7e586da521 | ||
|
2f5407736c | ||
|
b29a98e680 | ||
|
239a996846 | ||
|
421ecf527d | ||
|
c60e55832e | ||
|
b1f9568f8a | ||
|
dd1adb433b | ||
|
5a4bcfbac5 | ||
|
179642fa8f | ||
|
33111c4f31 | ||
|
03721e8090 | ||
|
0c025e0497 | ||
|
8cec40f672 | ||
|
4f7044bffa | ||
|
2c6c857e55 | ||
|
ede8c4ebad | ||
|
88dc85c401 | ||
|
7a94f78d78 | ||
|
6cacf6b2c5 | ||
|
69d198117e | ||
|
eb035c1023 | ||
|
6dabc7a331 | ||
|
58fe29e526 | ||
|
20688d6395 | ||
|
044dd7fea9 | ||
|
2c1f5081f2 | ||
|
9243e90e90 | ||
|
0dc7813c40 | ||
|
847bedb65c | ||
|
8e6e0cf673 | ||
|
0da3045c73 | ||
|
d8accce9e7 | ||
|
dbfd27301c | ||
|
5ef5d8c343 | ||
|
6ff8282f9f | ||
|
e3930e0588 | ||
|
129db0ad21 | ||
|
13a9ed902c | ||
|
4e9435418e | ||
|
9d990644dd | ||
|
c733cab39e | ||
|
5a004eeed4 | ||
|
012ce933d3 | ||
|
9a2c6bc1ff | ||
|
f0877e192d | ||
|
a04175a106 | ||
|
0cdd4cd6ed | ||
|
c64d2a4fa4 | ||
|
0b4b5a0b56 | ||
|
42ff4e877a | ||
|
96f206e1a0 | ||
|
d2bce7a1e2 | ||
|
f1f64eaba1 | ||
|
817eaf1bb2 | ||
|
16de3fdb97 | ||
|
2452f09714 | ||
|
b02c1311ff | ||
|
21c35f7744 | ||
|
bd2f22e9d7 | ||
|
d27776fd26 | ||
|
416ce15d1d | ||
|
9f9abc6d3e | ||
|
995f042fc8 | ||
|
e905a50ed0 | ||
|
5e036992b0 | ||
|
cb81a87200 | ||
|
fad2ba23b7 | ||
|
e093c0820d | ||
|
a65c729906 | ||
|
1b7fc89d8b | ||
|
246b8512e4 | ||
|
4209f0e86d | ||
|
653f57ea5d | ||
|
1766111e1d | ||
|
8caa08843e | ||
|
a08ae6f977 | ||
|
db246b38f2 | ||
|
1119d47a80 | ||
|
d4ddfad838 | ||
|
e2d0799d17 | ||
|
f68f31c80f | ||
|
b7c8e72ce2 | ||
|
9f5b286a45 | ||
|
1de204ff02 | ||
|
cc4d364f75 | ||
|
cd629065b8 | ||
|
46dfd9c44e | ||
|
edc797035f | ||
|
c1eb7d70b7 | ||
|
921636ea69 | ||
|
40c2262f47 | ||
|
b91e8fd0a1 | ||
|
ad7ca59532 | ||
|
802c16c0df | ||
|
efbb0e1b1c | ||
|
607f6e9b6c | ||
|
72b775e179 | ||
|
491f312036 | ||
|
31221062dd | ||
|
73e6fa24a8 | ||
|
b335fd30d1 | ||
|
37395003cb | ||
|
39b360b132 | ||
|
34b16c2da9 | ||
|
525108bf26 | ||
|
7563bdf900 | ||
|
64785be947 | ||
|
a714e9b985 | ||
|
bc69a8768c | ||
|
7d67761ed8 | ||
|
3d9b508335 | ||
|
78f0c8823d | ||
|
145a3c44b7 | ||
|
2063f51289 | ||
|
da2bfd8fc9 | ||
|
eedcd188c3 | ||
|
af8b5b63d5 | ||
|
4485d0833e | ||
|
a163a6af88 | ||
|
38bb5af04b | ||
|
11804d1cb8 | ||
|
22529fc1d2 | ||
|
62b0c5fd62 | ||
|
1ed28a4fd4 | ||
|
5044aea1d7 | ||
|
47fda0ccca | ||
|
742490775a | ||
|
95bbed2b21 | ||
|
2b446e227d | ||
|
ea6417dbb1 | ||
|
d902417959 | ||
|
358197db03 | ||
|
102fd1f1a1 | ||
|
fe9168c6cf | ||
|
f7663fc17f | ||
|
e370ec36ab | ||
|
8f181c74d4 | ||
|
8134ec84c6 | ||
|
e2fc5bcbb1 | ||
|
67af09c4bd | ||
|
b81abf4da5 | ||
|
2c288bb34d | ||
|
df762e8910 | ||
|
253aa19701 | ||
|
ea608d875b | ||
|
d4340a8b95 | ||
|
33b3aafb14 | ||
|
6cedc2fe35 | ||
|
1bb03015d9 | ||
|
c5bef72c63 | ||
|
8b7c00b337 | ||
|
6199d4fab4 | ||
|
2063d8647a | ||
|
c0a9f85f77 | ||
|
b1651c5b01 | ||
|
1a8af2caee | ||
|
a593070b9f | ||
|
1d700b0663 | ||
|
2233885142 | ||
|
faf87d2942 | ||
|
706673aed0 | ||
|
eaea688e33 | ||
|
256be3521d | ||
|
cec1e8593d | ||
|
e18f9be810 | ||
|
69dca2db43 | ||
|
c03321b55e | ||
|
f8b4287229 | ||
|
ce2fa0c116 | ||
|
ebe9a230fd | ||
|
a05ceea4bc | ||
|
67c2ded1f0 | ||
|
66d09c886e | ||
|
bbff7513f1 | ||
|
4195279c5d | ||
|
464d967a0a | ||
|
4dd7bf8916 | ||
|
4b134eaef9 | ||
|
ece62c8b18 | ||
|
b430c185b1 | ||
|
5c13ccf9e7 | ||
|
cb0e2ba305 | ||
|
011fbb38cb | ||
|
0a69450bc0 | ||
|
b4a97f4347 | ||
|
96f7aa95ee | ||
|
e92f2177e8 | ||
|
e4a3cc5290 | ||
|
a3a58a25ef | ||
|
ab7bda402e | ||
|
d78c168e16 | ||
|
2b04b4f008 | ||
|
a6cc0d742c | ||
|
e7e9a2755c | ||
|
56ea4ce2f5 | ||
|
05528f6dfd | ||
|
49cd36c33e | ||
|
898d96ce3a | ||
|
b257836b76 | ||
|
8fab4e56ef | ||
|
8e34c230d7 | ||
|
b32f2e2973 | ||
|
1047d1a598 | ||
|
1e5115b743 | ||
|
e1e12de91b | ||
|
58950afb99 | ||
|
89b64b165f | ||
|
90ab927bdc | ||
|
50ebf4eacb | ||
|
064ba080ab | ||
|
4ee0345217 | ||
|
d45cbbba98 | ||
|
612a7a7063 | ||
|
62e3ca4068 | ||
|
34dcdc89c3 | ||
|
bcc202ff3b | ||
|
d4a56b3823 | ||
|
626239038b | ||
|
f7da431e61 | ||
|
6ff59c423b | ||
|
076d4977e3 | ||
|
2a737a9d72 | ||
|
8c77a19eb6 | ||
|
3918696477 | ||
|
8a6a2b8577 | ||
|
584023380c | ||
|
1f4dd2bdb7 | ||
|
f7d5a0732b | ||
|
2a6cc882a5 | ||
|
f385339da3 | ||
|
c37566a999 | ||
|
f90a4b1374 | ||
|
b649680d3e | ||
|
b84c2abe14 | ||
|
58aff762c0 | ||
|
e5eef69c91 | ||
|
54f91b7a88 | ||
|
17676173ef | ||
|
31f4aa944a | ||
|
b30bd495b7 | ||
|
06f893436e | ||
|
20a2b3ed90 | ||
|
b1e3bc46c8 | ||
|
84509ec1a8 | ||
|
79df676a13 |
|
@ -1,17 +1,27 @@
|
|||
name: 🐞 ABS App Bug Report
|
||||
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## App Bug Description"
|
||||
value: '## App Bug Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thank you for filing a bug report! 🐛"
|
||||
value: 'Thank you for filing a bug report! 🐛'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: dropdown
|
||||
id: confirm-check
|
||||
attributes:
|
||||
label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22)
|
||||
multiple: false
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
@ -25,7 +35,7 @@ body:
|
|||
attributes:
|
||||
label: Steps to Reproduce the Issue
|
||||
description: Please help us understand how we can reliably reproduce the issue.
|
||||
placeholder: "1. Go to the library page of a Podcast library and..."
|
||||
placeholder: '1. Go to the library page of a Podcast library and...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
@ -38,13 +48,13 @@ body:
|
|||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Mobile Environment"
|
||||
value: '## Mobile Environment'
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
@ -62,10 +72,10 @@ body:
|
|||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.74
|
||||
- iOS App - 0.9.74
|
||||
- Android App - 0.9.73
|
||||
- iOS App - 0.9.73
|
||||
- 'Android App - 0.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -74,10 +84,10 @@ body:
|
|||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
- 'Google Play Store'
|
||||
- 'Testflight'
|
||||
- 'SideStore'
|
||||
- 'Other (List in "Additional Notes")'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
@ -85,4 +95,4 @@ body:
|
|||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: "e.g. I have tried X, Y, and Z."
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
|
@ -1,14 +1,14 @@
|
|||
name: 🚀 App Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: "[Enhancement]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## App Feature Request Description"
|
||||
value: '## App Feature Request Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Please first search in both issues & discussions for your enhancement and make sure your app is up to date."
|
||||
value: 'Please first search in both issues & discussions for your enhancement and make sure your app is up to date.'
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
|
@ -35,7 +35,7 @@ body:
|
|||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## App Current Implementation"
|
||||
value: '## App Current Implementation'
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
|
@ -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.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.74
|
||||
- iOS App - 0.9.74
|
||||
- Android App - 0.9.73
|
||||
- iOS App - 0.9.73
|
||||
- 'Android App - 0.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
41
.github/pull_request_template.md
vendored
Normal file
|
@ -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. -->
|
60
.github/workflows/build-apk.yml
vendored
|
@ -13,41 +13,41 @@ jobs:
|
|||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
|
|
42
.github/workflows/close_blank_issues.yaml
vendored
Normal file
|
@ -0,0 +1,42 @@
|
|||
name: Close Issues not using a template
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close_issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check issue headings
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||
const headings = [...issueBody.matchAll(headingRegex)];
|
||||
|
||||
if (headings.length < 3) {
|
||||
// Post a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||
});
|
||||
|
||||
// Close the issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed"
|
||||
});
|
||||
}
|
84
.github/workflows/deploy-apk.yml
vendored
|
@ -12,57 +12,57 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
@ -79,4 +79,4 @@ jobs:
|
|||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v4
|
||||
|
|
6
android/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"java.project.root": "android",
|
||||
"java.import.gradle.enabled": true,
|
||||
"java.import.gradle.wrapper.enabled": true,
|
||||
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
|
||||
}
|
|
@ -18,54 +18,55 @@ kotlin {
|
|||
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
]
|
||||
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.audiobookshelf.app'
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
}
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 113
|
||||
versionName "0.10.0-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
]
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 107
|
||||
versionName "0.9.76-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
]
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-debug"
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-debug"
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
flatDir {
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
mavenCentral()
|
||||
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
|
||||
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
|
||||
}
|
||||
|
||||
configurations.configureEach {
|
||||
|
@ -80,19 +81,18 @@ configurations.configureEach {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
|
||||
|
@ -120,7 +120,7 @@ dependencies {
|
|||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||
implementation "com.anggrayudi:storage:1.5.6"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
|
|
@ -9,7 +9,9 @@ android {
|
|||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':webnativellc-capacitor-filesharer')
|
||||
implementation project(':capacitor-community-keep-awake')
|
||||
implementation project(':capacitor-community-volume-buttons')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
|
|
|
@ -2,10 +2,16 @@
|
|||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
},
|
||||
"StatusBar": {
|
||||
"backgroundColor": "#232323",
|
||||
"style": "DARK"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"androidScheme": "http"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"pkg": "@webnativellc/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor-community/keep-awake",
|
||||
"classpath": "com.getcapacitor.community.keepawake.KeepAwakePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor-community/volume-buttons",
|
||||
"classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
|
|
@ -6,10 +6,15 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.webkit.WebView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
|
@ -18,6 +23,7 @@ import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
|||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
|
||||
|
@ -39,29 +45,58 @@ class MainActivity : BridgeActivity() {
|
|||
)
|
||||
|
||||
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)
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
registerPlugin(AbsLogger::class.java)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
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)
|
||||
|
||||
// Set margins
|
||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
leftMargin = left
|
||||
bottomMargin = bottom
|
||||
rightMargin = right
|
||||
topMargin = top
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
WindowInsets.CONSUMED
|
||||
} else {
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeStream(
|
||||
val index:Int,
|
||||
val codec_name:String,
|
||||
val codec_long_name:String,
|
||||
val channels:Int,
|
||||
val channel_layout:String,
|
||||
val duration:Double,
|
||||
val bit_rate:Double
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapterTags(
|
||||
val title:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapter(
|
||||
val id:Int,
|
||||
val start:Long,
|
||||
val end:Long,
|
||||
val tags:AudioProbeChapterTags?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
val startS = start / 1000.0
|
||||
val endS = end / 1000.0
|
||||
val title = tags?.title ?: "Chapter $id"
|
||||
return BookChapter(id, startS, endS, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormatTags(
|
||||
val artist:String?,
|
||||
val album:String?,
|
||||
val comment:String?,
|
||||
val date:String?,
|
||||
val genre:String?,
|
||||
val title:String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormat(
|
||||
val filename:String,
|
||||
val format_name:String,
|
||||
val duration:Double,
|
||||
val size:Long,
|
||||
val bit_rate:Double,
|
||||
val tags:AudioProbeFormatTags?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class AudioProbeResult (
|
||||
val streams:MutableList<AudioProbeStream>,
|
||||
val chapters:MutableList<AudioProbeChapter>,
|
||||
val format:AudioProbeFormat) {
|
||||
|
||||
val duration get() = format.duration
|
||||
val size get() = format.size
|
||||
val title get() = format.tags?.title ?: format.filename.split("/").last()
|
||||
val artist get() = format.tags?.artist ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapters(): List<BookChapter> {
|
||||
if (chapters.isEmpty()) return mutableListOf()
|
||||
return chapters.map { it.getBookChapter() }
|
||||
}
|
||||
}
|
|
@ -5,30 +5,34 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
var index: Int,
|
||||
var startOffset: Double,
|
||||
var duration: Double,
|
||||
var title: String,
|
||||
var contentUrl: String,
|
||||
var mimeType: String,
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
// TODO: This should no longer be necessary
|
||||
var serverIndex: Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
val startOffsetMs
|
||||
get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
val durationMs
|
||||
get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
val endOffsetMs
|
||||
get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
val relPath
|
||||
get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
fun getBookChapter(): BookChapter {
|
||||
return BookChapter(index + 1, startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class CollapsedSeries(
|
||||
id:String,
|
||||
var libraryId:String?,
|
||||
var name:String,
|
||||
//var nameIgnorePrefix:String,
|
||||
var sequence:String?,
|
||||
var libraryItemIds:MutableList<String>
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = name
|
||||
@get:JsonIgnore
|
||||
val numBooks get() = libraryItemIds.size
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
val mediaId = "__LIBRARY__${libraryId}__SERIE__${id}"
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
//.setIconUri(getCoverUri())
|
||||
.setSubtitle("${numBooks} books")
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -1,12 +1,15 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.icu.text.DateFormat
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.fasterxml.jackson.annotation.*
|
||||
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
||||
import java.util.Date
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||
|
@ -25,7 +28,8 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||
open fun removeAudioTrack(localFileId:String) { }
|
||||
@JsonIgnore
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||
|
||||
@JsonIgnore
|
||||
open fun checkHasTracks():Boolean { return false }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
@ -93,6 +97,11 @@ class Podcast(
|
|||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun checkHasTracks():Boolean {
|
||||
return (episodes?.size ?: numEpisodes ?: 0) > 0
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val localEpisodeId = "local_ep_" + episode.id
|
||||
|
@ -182,6 +191,11 @@ class Book(
|
|||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun checkHasTracks():Boolean {
|
||||
return (tracks?.size ?: numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
||||
|
@ -214,7 +228,9 @@ class BookMetadata(
|
|||
var authorName:String?,
|
||||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
var seriesName:String?,
|
||||
@JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY])
|
||||
var series:List<SeriesType>?
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||
|
@ -299,11 +315,18 @@ data class PodcastEpisode(
|
|||
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
val mediaId = localEpisodeId ?: id
|
||||
var subtitle = libraryItemDescription.title
|
||||
if (publishedAt !== null) {
|
||||
val sdf = DateFormat.getDateInstance()
|
||||
val publishedAtDT = Date(publishedAt!!)
|
||||
subtitle = "${sdf.format(publishedAtDT)} / $subtitle"
|
||||
}
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(libraryItemDescription.title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
|
||||
libraryItemDescription.iconBitmap?.let {
|
||||
|
@ -342,18 +365,39 @@ data class Library(
|
|||
var name:String,
|
||||
var folders:MutableList<Folder>,
|
||||
var icon:String,
|
||||
var mediaType:String
|
||||
var mediaType:String,
|
||||
var stats: LibraryStats?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
fun getMediaMetadata(context: Context, targetType: String? = null): MediaMetadataCompat {
|
||||
var mediaId = id
|
||||
if (targetType !== null) {
|
||||
mediaId = "__RECENTLY__$id"
|
||||
}
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString())
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryStats(
|
||||
var totalItems: Int,
|
||||
var totalSize: Long,
|
||||
var totalDuration: Double,
|
||||
var numAudioFiles: Int
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SeriesType(
|
||||
var id: String,
|
||||
var name: String,
|
||||
var sequence: String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Folder(
|
||||
var id:String,
|
||||
|
@ -387,3 +431,84 @@ data class LibraryItemWithEpisode(
|
|||
var libraryItemWrapper:LibraryItemWrapper,
|
||||
var episode:PodcastEpisode
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryItemSearchResultSeriesItemType(
|
||||
var series: LibrarySeriesItem,
|
||||
var books: List<LibraryItem>?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryItemSearchResultLibraryItemType(
|
||||
val libraryItem: LibraryItem
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryItemSearchResultType(
|
||||
var book:List<LibraryItemSearchResultLibraryItemType>?,
|
||||
var podcast:List<LibraryItemSearchResultLibraryItemType>?,
|
||||
var series:List<LibraryItemSearchResultSeriesItemType>?,
|
||||
var authors:List<LibraryAuthorItem>?
|
||||
)
|
||||
|
||||
// For personalized shelves
|
||||
@JsonTypeInfo(
|
||||
use=JsonTypeInfo.Id.NAME,
|
||||
property = "type",
|
||||
include = JsonTypeInfo.As.PROPERTY,
|
||||
visible = true
|
||||
)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"),
|
||||
JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"),
|
||||
JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"),
|
||||
JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"),
|
||||
JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast")
|
||||
)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
sealed class LibraryShelfType(
|
||||
open val id: String,
|
||||
open val label: String,
|
||||
open val total: Int,
|
||||
open val type: String,
|
||||
)
|
||||
|
||||
data class LibraryShelfBookEntity(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val total: Int,
|
||||
override val type: String,
|
||||
val entities: List<LibraryItem>?
|
||||
) : LibraryShelfType(id, label, total, type)
|
||||
|
||||
data class LibraryShelfSeriesEntity(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val total: Int,
|
||||
override val type: String,
|
||||
val entities: List<LibrarySeriesItem>?
|
||||
) : LibraryShelfType(id, label, total, type)
|
||||
|
||||
data class LibraryShelfAuthorEntity(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val total: Int,
|
||||
override val type: String,
|
||||
val entities: List<LibraryAuthorItem>?
|
||||
) : LibraryShelfType(id, label, total, type)
|
||||
|
||||
data class LibraryShelfEpisodeEntity(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val total: Int,
|
||||
override val type: String,
|
||||
val entities: List<LibraryItem>?
|
||||
) : LibraryShelfType(id, label, total, type)
|
||||
|
||||
data class LibraryShelfPodcastEntity(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val total: Int,
|
||||
override val type: String,
|
||||
val entities: List<LibraryItem>?
|
||||
) : LibraryShelfType(id, label, total, type)
|
||||
|
|
|
@ -28,11 +28,18 @@ enum class StreamingUsingCellularSetting {
|
|||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
// version added after 0.9.81-beta
|
||||
var version:String?,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
|
@ -131,9 +138,12 @@ data class DeviceSettings(
|
|||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
|
@ -157,9 +167,12 @@ data class DeviceSettings(
|
|||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -180,9 +193,9 @@ data class DeviceSettings(
|
|||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||
else {
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.json.JSONObject
|
||||
|
||||
|
@ -18,8 +17,7 @@ data class ItemInProgress(
|
|||
val isLocal: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||
|
||||
var episode:PodcastEpisode? = null
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LibraryAuthorItem(
|
||||
id:String,
|
||||
var libraryId:String,
|
||||
var name:String,
|
||||
var description:String?,
|
||||
var imagePath:String?,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var numBooks:Int?,
|
||||
var libraryItems:MutableList<LibraryItem>?,
|
||||
var series:MutableList<LibrarySeriesItem>?
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = name
|
||||
|
||||
@get:JsonIgnore
|
||||
val bookCount get() = if (numBooks != null) numBooks else libraryItems!!.size
|
||||
|
||||
@JsonIgnore
|
||||
fun getPortraitUri(): Uri {
|
||||
if (imagePath == null) {
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.md_account_outline)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/authors/$id/image?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
if (groupTitle !== null) {
|
||||
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||
}
|
||||
|
||||
val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}"
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(getPortraitUri())
|
||||
.setSubtitle("${bookCount} books")
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
return getMediaDescription(progress, ctx, null)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LibraryCollection(
|
||||
id:String,
|
||||
var libraryId:String,
|
||||
var name:String,
|
||||
//var userId:String?,
|
||||
var description:String?,
|
||||
var books:MutableList<LibraryItem>?,
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = name
|
||||
|
||||
@get:JsonIgnore
|
||||
val bookCount get() = if (books != null) books!!.size else 0
|
||||
|
||||
@get:JsonIgnore
|
||||
val audiobookCount get() = books?.filter { book -> (book.media as Book).getAudioTracks().isNotEmpty() }?.size ?: 0
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
val mediaId = "__LIBRARY__${libraryId}__COLLECTION__${id}"
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
//.setIconUri(getCoverUri())
|
||||
.setSubtitle("${bookCount} books")
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -32,10 +32,18 @@ class LibraryItem(
|
|||
var media:MediaType,
|
||||
var libraryFiles:MutableList<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
|
||||
var localLibraryItemId:String? // For Android Auto
|
||||
var collapsedSeries: CollapsedSeries?,
|
||||
var localLibraryItemId:String?, // For Android Auto
|
||||
val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
val title: String
|
||||
get() {
|
||||
if (collapsedSeries != null) {
|
||||
return collapsedSeries!!.title
|
||||
}
|
||||
return media.metadata.title
|
||||
}
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
|
@ -45,62 +53,126 @@ class LibraryItem(
|
|||
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}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
return media.checkHasTracks()
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val seriesSequence: String
|
||||
get() {
|
||||
if (mediaType != "podcast") {
|
||||
return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val seriesSequenceParts: List<String>
|
||||
get() {
|
||||
if (seriesSequence.isEmpty()) {
|
||||
return listOf("")
|
||||
}
|
||||
return seriesSequence.split(".", limit = 2)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (collapsedSeries == null) {
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
if (media.metadata.explicit) {
|
||||
extras.putLong(
|
||||
MediaConstants.METADATA_KEY_IS_EXPLICIT,
|
||||
MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
|
||||
)
|
||||
}
|
||||
}
|
||||
if (groupTitle !== null) {
|
||||
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||
}
|
||||
|
||||
val mediaId = if (localLibraryItemId != null) {
|
||||
localLibraryItemId
|
||||
} else if (collapsedSeries != null) {
|
||||
if (authorId != null) {
|
||||
"__LIBRARY__${libraryId}__AUTHOR_SERIES__${authorId}__${collapsedSeries!!.id}"
|
||||
} else {
|
||||
"__LIBRARY__${libraryId}__SERIES__${collapsedSeries!!.id}"
|
||||
}
|
||||
} else {
|
||||
id
|
||||
}
|
||||
var subtitle = authorName
|
||||
if (collapsedSeries != null) {
|
||||
subtitle = "${collapsedSeries!!.numBooks} books"
|
||||
}
|
||||
var itemTitle = title
|
||||
if (showSeriesNumber == true && seriesSequence != "") {
|
||||
itemTitle = "$seriesSequence. $itemTitle"
|
||||
}
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(itemTitle)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat {
|
||||
return getMediaDescription(progress, ctx, authorId, showSeriesNumber, null)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat {
|
||||
return getMediaDescription(progress, ctx, authorId, null, null)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
if (media.metadata.explicit) {
|
||||
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||
}
|
||||
|
||||
val mediaId = localLibraryItemId ?: id
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
/*
|
||||
This is needed so Android auto library hierarchy for author series can be implemented
|
||||
*/
|
||||
return getMediaDescription(progress, ctx, null, null, null)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LibrarySeriesItem(
|
||||
id:String,
|
||||
var libraryId:String,
|
||||
var name:String,
|
||||
var description:String?,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var books:MutableList<LibraryItem>?,
|
||||
var localLibraryItemId:String? // For Android Auto
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = name
|
||||
|
||||
@get:JsonIgnore
|
||||
val audiobookCount: Int
|
||||
get() {
|
||||
if (books == null) return 0
|
||||
val booksWithAudio = books?.filter { b -> b.media.checkHasTracks() }
|
||||
return booksWithAudio?.size ?: 0
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
if (groupTitle !== null) {
|
||||
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||
}
|
||||
|
||||
val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}"
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
//.setIconUri(getCoverUri())
|
||||
.setSubtitle("$audiobookCount books")
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
return getMediaDescription(progress, ctx, null)
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
|
@ -78,6 +79,27 @@ class LocalLibraryItem(
|
|||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
}
|
||||
if (audioTracks.size == 0) return false
|
||||
audioTracks.forEach {
|
||||
// Check that metadata is not null
|
||||
if (it.metadata === null) {
|
||||
return false
|
||||
}
|
||||
// Check that file exists
|
||||
val file = File(it.metadata!!.path)
|
||||
if (!file.exists()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
|
|
|
@ -4,70 +4,65 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
|||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
/*
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaItem(
|
||||
var id:String,
|
||||
var name: String,
|
||||
var mediaType:String,
|
||||
var folderId:String,
|
||||
var contentUrl:String,
|
||||
var simplePath: String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
var id: String,
|
||||
var name: String,
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var ebookFile: EBookFile?,
|
||||
var localFiles: MutableList<LocalFile>,
|
||||
var coverContentUrl: String?,
|
||||
var coverAbsolutePath: String?
|
||||
) {
|
||||
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
fun getDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalSize():Long {
|
||||
fun getTotalSize(): Long {
|
||||
var total = 0L
|
||||
localFiles.forEach { total += it.size }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata():MediaTypeMetadata {
|
||||
fun getMediaMetadata(): MediaTypeMetadata {
|
||||
return if (mediaType == "book") {
|
||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||
BookMetadata(
|
||||
name,
|
||||
null,
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getAudiobookChapters():List<BookChapter> {
|
||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
||||
}
|
||||
// Multi-track make chapters from tracks
|
||||
return audioTracks.map { it.getBookChapter() }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getLocalLibraryItem():LocalLibraryItem {
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
||||
PodcastMetadata(name, null, null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,14 +4,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemHistory(
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal:Boolean,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var createdAt: Long,
|
||||
var events:MutableList<MediaItemEvent>,
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal: Boolean,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var serverUserId: String?,
|
||||
var createdAt: Long,
|
||||
var events: MutableList<MediaItemEvent>,
|
||||
)
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.audiobookshelf.app.BuildConfig
|
|||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.player.*
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
|
@ -19,61 +20,70 @@ import com.google.android.exoplayer2.MediaMetadata
|
|||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import com.audiobookshelf.app.player.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
var id:String,
|
||||
var userId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath:String?,
|
||||
var duration:Double,
|
||||
var playMethod:Int,
|
||||
var startedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var timeListening:Long,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var currentTime:Double,
|
||||
var libraryItem:LibraryItem?,
|
||||
var localLibraryItem:LocalLibraryItem?,
|
||||
var localEpisodeId:String?,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var mediaPlayer:String?
|
||||
var id: String,
|
||||
var userId: String?,
|
||||
var libraryItemId: String?,
|
||||
var episodeId: String?,
|
||||
var mediaType: String,
|
||||
var mediaMetadata: MediaTypeMetadata,
|
||||
var deviceInfo: DeviceInfo,
|
||||
var chapters: List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath: String?,
|
||||
var duration: Double,
|
||||
var playMethod: Int,
|
||||
var startedAt: Long,
|
||||
var updatedAt: Long,
|
||||
var timeListening: Long,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var currentTime: Double,
|
||||
var libraryItem: LibraryItem?,
|
||||
var localLibraryItem: LocalLibraryItem?,
|
||||
var localEpisodeId: String?,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var mediaPlayer: String?
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
val isHLS
|
||||
get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
@get:JsonIgnore
|
||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
val isDirectPlay
|
||||
get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
val isLocal
|
||||
get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
val isPodcastEpisode
|
||||
get() = mediaType == "podcast"
|
||||
@get:JsonIgnore
|
||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||
val currentTimeMs
|
||||
get() = (currentTime * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||
val totalDurationMs
|
||||
get() = (getTotalDuration() * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
||||
val localLibraryItemId
|
||||
get() = localLibraryItem?.id ?: ""
|
||||
@get:JsonIgnore
|
||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val localMediaProgressId
|
||||
get() =
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
@get:JsonIgnore
|
||||
val progress get() = currentTime / getTotalDuration()
|
||||
val progress
|
||||
get() = currentTime / getTotalDuration()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
@get:JsonIgnore
|
||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
val mediaItemId
|
||||
get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
fun getCurrentTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
|
@ -84,7 +94,7 @@ class PlaybackSession(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackIndex():Int {
|
||||
fun getNextTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs < track.startOffsetMs) {
|
||||
|
@ -95,68 +105,100 @@ class PlaybackSession(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
fun getChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
return chapters.find { time >= it.startMs && it.endMs > time }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackEndTime():Long {
|
||||
fun getCurrentTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
||||
fun getNextChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackEndTime():Long {
|
||||
fun getNextTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
fun getCurrentTrackTimeMs(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
val time = currentTime - currentTrack.startOffset
|
||||
return (time * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
fun getTrackStartOffsetMs(index: Int): Long {
|
||||
if (index < 0 || index >= audioTracks.size) return 0L
|
||||
val currentTrack = audioTracks[index]
|
||||
return (currentTrack.startOffset * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalDuration():Double {
|
||||
fun getTotalDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||
// Safety check this playback session is the same one currently connected (should always be)
|
||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx: Context): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||
if (coverUri.toString().startsWith("file:")) {
|
||||
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
||||
coverUri =
|
||||
FileProvider.getUriForFile(
|
||||
ctx,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
coverUri.toFile()
|
||||
)
|
||||
}
|
||||
|
||||
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return coverUri
|
||||
?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
if (coverPath == null)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
if (checkIsServerVersionGte("2.17.0")) {
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||
}
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
||||
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||
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}")
|
||||
}
|
||||
|
||||
|
@ -164,28 +206,34 @@ class PlaybackSession(
|
|||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
val metadataBuilder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
)
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val 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)
|
||||
}
|
||||
val 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)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
|
@ -194,26 +242,27 @@ class PlaybackSession(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx: Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
val metadataBuilder =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
fun getMediaItems(ctx: Context): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
|
@ -221,50 +270,107 @@ class PlaybackSession(
|
|||
val mimeType = audioTrack.mimeType
|
||||
|
||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
val mediaItem =
|
||||
MediaItem.Builder()
|
||||
.setUri(mediaUri)
|
||||
.setTag(queueItem)
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setMimeType(mimeType)
|
||||
.build()
|
||||
mediaItems.add(mediaItem)
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
||||
fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata =
|
||||
com.google.android.gms.cast.MediaMetadata(
|
||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||
)
|
||||
|
||||
// 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 {
|
||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
||||
castMetadata.addImage(WebImage(coverUri))
|
||||
}
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ARTIST,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE,
|
||||
audioTrack.title
|
||||
)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
castMetadata.putInt(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER,
|
||||
audioTrack.index
|
||||
)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
||||
fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem {
|
||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||
|
||||
val mediaUri = getContentUri(audioTrack)
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}.build()
|
||||
val mediaInfo =
|
||||
MediaInfo.Builder(mediaUri.toString())
|
||||
.apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}
|
||||
.build()
|
||||
|
||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
||||
setPlaybackDuration(audioTrack.duration)
|
||||
}.build()
|
||||
return MediaQueueItem.Builder(mediaInfo)
|
||||
.apply { setPlaybackDuration(audioTrack.duration) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun clone():PlaybackSession {
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
fun clone(): PlaybackSession {
|
||||
return PlaybackSession(
|
||||
id,
|
||||
userId,
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
mediaType,
|
||||
mediaMetadata,
|
||||
deviceInfo,
|
||||
chapters,
|
||||
displayTitle,
|
||||
displayAuthor,
|
||||
coverPath,
|
||||
duration,
|
||||
playMethod,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
timeListening,
|
||||
audioTracks,
|
||||
currentTime,
|
||||
libraryItem,
|
||||
localLibraryItem,
|
||||
localEpisodeId,
|
||||
serverConnectionConfigId,
|
||||
serverAddress,
|
||||
mediaPlayer
|
||||
)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
|
@ -275,7 +381,25 @@ class PlaybackSession(
|
|||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
fun getNewLocalMediaProgress(): LocalMediaProgress {
|
||||
return LocalMediaProgress(
|
||||
localMediaProgressId,
|
||||
localLibraryItemId,
|
||||
localEpisodeId,
|
||||
getTotalDuration(),
|
||||
progress,
|
||||
currentTime,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
updatedAt,
|
||||
startedAt,
|
||||
null,
|
||||
serverConnectionConfigId,
|
||||
serverAddress,
|
||||
userId,
|
||||
libraryItemId,
|
||||
episodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,11 +12,19 @@ import com.audiobookshelf.app.managers.DbManager
|
|||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
/** Interface for widget event handling. */
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
/**
|
||||
* Called when the player state changes.
|
||||
* @param pns The PlayerNotificationService instance.
|
||||
*/
|
||||
fun onPlayerChanged(pns: PlayerNotificationService)
|
||||
|
||||
/** Called when the player is closed. */
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
/** Singleton object for managing device-related operations. */
|
||||
object DeviceManager {
|
||||
const val tag = "DeviceManager"
|
||||
|
||||
|
@ -25,20 +33,29 @@ object DeviceManager {
|
|||
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||
|
||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||
val token get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer get() = serverConnectionConfig != null
|
||||
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||
val serverAddress
|
||||
get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId
|
||||
get() = serverConnectionConfig?.userId ?: ""
|
||||
val token
|
||||
get() = serverConnectionConfig?.token ?: ""
|
||||
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||
val isConnectedToServer
|
||||
get() = serverConnectionConfig != null
|
||||
|
||||
var widgetUpdater:WidgetEventEmitter? = null
|
||||
var widgetUpdater: WidgetEventEmitter? = null
|
||||
|
||||
init {
|
||||
Log.d(tag, "Device Manager Singleton invoked")
|
||||
|
||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null ||
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime == null
|
||||
) {
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00"
|
||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||
}
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
|
@ -48,6 +65,10 @@ object DeviceManager {
|
|||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
// Initialize sleep timer almost done chime added in v0.9.81
|
||||
if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) {
|
||||
deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false
|
||||
}
|
||||
|
||||
// Language added in v0.9.69
|
||||
if (deviceData.deviceSettings?.languageCode == null) {
|
||||
|
@ -61,19 +82,79 @@ object DeviceManager {
|
|||
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||
}
|
||||
if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) {
|
||||
deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100
|
||||
}
|
||||
if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) {
|
||||
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder =
|
||||
AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
/**
|
||||
* Encodes the given ID to a Base64 string.
|
||||
* @param id The ID to encode.
|
||||
* @return The Base64 encoded string.
|
||||
*/
|
||||
fun getBase64Id(id: String): String {
|
||||
return android.util.Base64.encodeToString(
|
||||
id.toByteArray(),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
/**
|
||||
* Retrieves the server connection configuration for the given ID.
|
||||
* @param id The ID of the server connection configuration.
|
||||
* @return The ServerConnectionConfig instance or null if not found.
|
||||
*/
|
||||
fun getServerConnectionConfig(id: String?): ServerConnectionConfig? {
|
||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||
}
|
||||
|
||||
fun checkConnectivity(ctx:Context): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
/**
|
||||
* Check if the currently connected server version is >= compareVersion
|
||||
* Abs server only uses major.minor.patch
|
||||
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||
*
|
||||
* @example
|
||||
* serverVersion=2.25.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||
*
|
||||
* serverVersion=2.26.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||
*/
|
||||
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||
if (serverVersion == "") return false
|
||||
if (compareVersion == "") return true
|
||||
|
||||
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
// Compare major, minor, and patch components
|
||||
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||
|
||||
if (serverVersionComponent < compareVersionComponent) {
|
||||
return false // Server version is less than compareVersion
|
||||
} else if (serverVersionComponent > compareVersionComponent) {
|
||||
return true // Server version is greater than compareVersion
|
||||
}
|
||||
}
|
||||
|
||||
return true // versions are equal in major, minor, and patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the network connectivity status.
|
||||
* @param ctx The context to use for checking connectivity.
|
||||
* @return True if connected to the internet, false otherwise.
|
||||
*/
|
||||
fun checkConnectivity(ctx: Context): Boolean {
|
||||
val connectivityManager =
|
||||
ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
|
@ -90,35 +171,58 @@ object DeviceManager {
|
|||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
/**
|
||||
* Sets the last playback session.
|
||||
* @param playbackSession The playback session to set.
|
||||
*/
|
||||
fun setLastPlaybackSession(playbackSession: PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
|
||||
fun initializeWidgetUpdater(context:Context) {
|
||||
/**
|
||||
* Initializes the widget updater.
|
||||
* @param context The context to use for initializing the widget updater.
|
||||
*/
|
||||
fun initializeWidgetUpdater(context: Context) {
|
||||
Log.d(tag, "Initializing widget updater")
|
||||
widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
widgetUpdater =
|
||||
(object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(
|
||||
context,
|
||||
appWidgetManager,
|
||||
widgetId,
|
||||
playbackSession,
|
||||
isPlaying,
|
||||
PlayerNotificationService.isClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
override fun onPlayerClosed() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
override fun onPlayerClosed() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(
|
||||
context,
|
||||
appWidgetManager,
|
||||
widgetId,
|
||||
deviceData.lastPlaybackSession,
|
||||
false,
|
||||
PlayerNotificationService.isClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,83 +13,144 @@ import java.io.File
|
|||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||
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}")
|
||||
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}"
|
||||
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,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
file.name,
|
||||
file.extension,
|
||||
file.absolutePath,
|
||||
file.getBasePath(ctx),
|
||||
file.length()
|
||||
)
|
||||
// Create new audio track
|
||||
val track = AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
null,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
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,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
|
@ -98,40 +159,51 @@ class FolderScanner(var ctx: Context) {
|
|||
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}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
|
||||
} else if (downloadItemPart.ebookFile != null) {
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
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(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
|
@ -141,7 +213,10 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
|
@ -163,30 +238,37 @@ class FolderScanner(var ctx: Context) {
|
|||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
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}")
|
||||
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
|
||||
|
@ -206,7 +288,7 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
|
@ -235,72 +317,188 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
||||
)
|
||||
|
||||
// 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/*"))
|
||||
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?
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
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}")
|
||||
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}")
|
||||
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())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
||||
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}")
|
||||
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}")
|
||||
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())
|
||||
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)
|
||||
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())
|
||||
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
|
||||
|
@ -309,7 +507,10 @@ class FolderScanner(var ctx: Context) {
|
|||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
|
@ -331,30 +532,37 @@ class FolderScanner(var ctx: Context) {
|
|||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
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}")
|
||||
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)
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
|||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLog
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
|
@ -22,45 +24,47 @@ class DbManager {
|
|||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
return Paper.book("device").read("data")
|
||||
?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
fun getLocalLibraryItems(mediaType: String? = null): MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems: MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
||||
if (localLibraryItem != null &&
|
||||
(mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)
|
||||
) {
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
}
|
||||
}
|
||||
return localLibraryItems
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
||||
fun getLocalLibraryItemsInFolder(folderId: String): List<LocalLibraryItem> {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
return localLibraryItems.filter {
|
||||
it.folderId == folderId
|
||||
}
|
||||
return localLibraryItems.filter { it.folderId == folderId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? {
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
val localLibraryItem =
|
||||
getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
return if (localLibraryItem != null) {
|
||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||
} else {
|
||||
|
@ -68,14 +72,12 @@ class DbManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||
fun removeLocalLibraryItem(localLibraryItemId: String) {
|
||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
||||
localLibraryItems.map {
|
||||
Paper.book("localLibraryItems").write(it.id, it)
|
||||
}
|
||||
fun saveLocalLibraryItems(localLibraryItems: List<LocalLibraryItem>) {
|
||||
localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) }
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
|
@ -83,28 +85,24 @@ class DbManager {
|
|||
}
|
||||
|
||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
Paper.book("localFolders").write(localFolder.id, localFolder)
|
||||
}
|
||||
|
||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||
fun getLocalFolder(folderId: String): LocalFolder? {
|
||||
return Paper.book("localFolders").read(folderId)
|
||||
}
|
||||
|
||||
fun getAllLocalFolders():List<LocalFolder> {
|
||||
val localFolders:MutableList<LocalFolder> = mutableListOf()
|
||||
fun getAllLocalFolders(): List<LocalFolder> {
|
||||
val localFolders: MutableList<LocalFolder> = mutableListOf()
|
||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
||||
localFolders.add(it)
|
||||
}
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { localFolders.add(it) }
|
||||
}
|
||||
return localFolders
|
||||
}
|
||||
|
||||
fun removeLocalFolder(folderId:String) {
|
||||
fun removeLocalFolder(folderId: String) {
|
||||
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||
localLibraryItems.forEach {
|
||||
Paper.book("localLibraryItems").delete(it.id)
|
||||
}
|
||||
localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) }
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
|
@ -112,29 +110,28 @@ class DbManager {
|
|||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
fun removeDownloadItem(downloadItemId:String) {
|
||||
fun removeDownloadItem(downloadItemId: String) {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<DownloadItem> {
|
||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||
fun getDownloadItems(): List<DownloadItem> {
|
||||
val downloadItems: MutableList<DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||
downloadItems.add(it)
|
||||
}
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let { downloadItems.add(it) }
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||
}
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be
|
||||
// "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? {
|
||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||
}
|
||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
||||
fun getAllLocalMediaProgress(): List<LocalMediaProgress> {
|
||||
val mediaProgress: MutableList<LocalMediaProgress> = mutableListOf()
|
||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||
mediaProgress.add(it)
|
||||
|
@ -142,7 +139,7 @@ class DbManager {
|
|||
}
|
||||
return mediaProgress
|
||||
}
|
||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
||||
fun removeLocalMediaProgress(localMediaProgressId: String) {
|
||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||
}
|
||||
|
||||
|
@ -158,35 +155,50 @@ class DbManager {
|
|||
var hasUpdates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as MutableList<LocalFile>
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
// Check audio tracks and episodes
|
||||
if (lli.isPodcast) {
|
||||
val podcast = lli.media as Podcast
|
||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
podcast.episodes =
|
||||
podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null &&
|
||||
lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as
|
||||
MutableList<PodcastEpisode>
|
||||
} else {
|
||||
val book = lli.media as Book
|
||||
book.tracks = book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as MutableList<AudioTrack>
|
||||
book.tracks =
|
||||
book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as
|
||||
MutableList<AudioTrack>
|
||||
}
|
||||
|
||||
// Check cover still there
|
||||
|
@ -194,14 +206,22 @@ class DbManager {
|
|||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
lli.coverAbsolutePath = null
|
||||
lli.coverContentUrl = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
if (lli.serverConnectionConfigId == null) {
|
||||
// Local-only item support was removed in app version 0.9.67, remove any remaining local
|
||||
// only items beginning in 0.9.80
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS")
|
||||
Paper.book("localLibraryItems").delete(lli.id)
|
||||
} else if (hasUpdates) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||
}
|
||||
|
@ -215,11 +235,18 @@ class DbManager {
|
|||
localMediaProgress.forEach {
|
||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||
if (!it.id.startsWith("local")) {
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
||||
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id
|
||||
// causing duplicate progress. Remove them.
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI.isPodcast) {
|
||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||
|
@ -229,7 +256,10 @@ class DbManager {
|
|||
val podcast = matchingLLI.media as Podcast
|
||||
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||
if (matchingLEp == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
}
|
||||
}
|
||||
|
@ -238,20 +268,20 @@ class DbManager {
|
|||
}
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
fun getMediaItemHistory(id: String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
Paper.book("playbackSession").write(playbackSession.id, playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
fun removePlaybackSession(playbackSessionId: String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
fun getPlaybackSessions():List<PlaybackSession> {
|
||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
||||
fun getPlaybackSessions(): List<PlaybackSession> {
|
||||
val sessions: MutableList<PlaybackSession> = mutableListOf()
|
||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||
sessions.add(it)
|
||||
|
@ -259,4 +289,35 @@ class DbManager {
|
|||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
fun saveLog(log:AbsLog) {
|
||||
Paper.book("log").write(log.id, log)
|
||||
}
|
||||
fun getAllLogs() : List<AbsLog> {
|
||||
val logs:MutableList<AbsLog> = mutableListOf()
|
||||
Paper.book("log").allKeys.forEach { logId ->
|
||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||
logs.add(it)
|
||||
}
|
||||
}
|
||||
return logs.sortedBy { it.timestamp }
|
||||
}
|
||||
fun removeAllLogs() {
|
||||
Paper.book("log").destroy()
|
||||
}
|
||||
fun cleanLogs() {
|
||||
val numberOfHoursToKeep = 48
|
||||
val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep)
|
||||
val allLogs = getAllLogs()
|
||||
var logsRemoved = 0
|
||||
allLogs.forEach {
|
||||
if (it.timestamp < keepLogCutoff) {
|
||||
Paper.book("log").delete(it.id)
|
||||
logsRemoved++
|
||||
}
|
||||
}
|
||||
if (logsRemoved > 0) {
|
||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,18 +18,26 @@ import com.audiobookshelf.app.models.DownloadItemPart
|
|||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
/** Manages download items and their parts. */
|
||||
class DownloadItemManager(
|
||||
var downloadManager: DownloadManager,
|
||||
private var folderScanner: FolderScanner,
|
||||
var mainActivity: MainActivity,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
|
@ -37,25 +45,28 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
||||
var downloadItemQueue: MutableList<DownloadItem> =
|
||||
mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
||||
mutableListOf() // Item parts currently being downloaded
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem:DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
var isDownloading: Boolean = false
|
||||
}
|
||||
|
||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
||||
/** Adds a download item to the queue and starts processing the queue. */
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
|
||||
|
@ -64,42 +75,18 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
checkUpdateDownloadQueue()
|
||||
}
|
||||
|
||||
/** Checks and updates the download queue. */
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
||||
Log.d(
|
||||
tag,
|
||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
||||
)
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
val file = File(it.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
||||
val internalProgressCallback = (object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
||||
it.bytesDownloaded = totalBytesWritten
|
||||
it.progress = progress
|
||||
}
|
||||
override fun onComplete(failed:Boolean) {
|
||||
it.failed = failed
|
||||
it.completed = true
|
||||
}
|
||||
})
|
||||
|
||||
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
||||
it.downloadId = 1
|
||||
currentDownloadItemParts.add(it)
|
||||
} else {
|
||||
val dlRequest = it.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
it.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(it)
|
||||
}
|
||||
}
|
||||
if (nextDownloadItemParts.isNotEmpty()) {
|
||||
processDownloadItemParts(nextDownloadItemParts)
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
|
@ -107,9 +94,59 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
}
|
||||
|
||||
/** Processes the download item parts. */
|
||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
startInternalDownload(it)
|
||||
} else {
|
||||
startExternalDownload(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
||||
downloadItemPart.progress = progress
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
downloadItemPart.failed = failed
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
||||
)
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
||||
.download(downloadItemPart.serverUrl)
|
||||
downloadItemPart.downloadId = 1
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts an external download. */
|
||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts watching the downloads. */
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
|
@ -117,25 +154,13 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
while (currentDownloadItemParts.isNotEmpty()) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let {
|
||||
checkDownloadItemFinished(it)
|
||||
}
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
handleInternalDownloadPart(downloadItemPart)
|
||||
} else {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
handleExternalDownloadPart(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -151,7 +176,29 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
||||
/** 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)
|
||||
|
@ -159,12 +206,17 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
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 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")
|
||||
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 -> {
|
||||
|
@ -183,8 +235,12 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
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
|
||||
|
||||
|
@ -200,84 +256,120 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
||||
/** Handles the result of a download item part check. */
|
||||
private fun handleDownloadItemPartCheck(
|
||||
downloadCheckStatus: DownloadCheckStatus,
|
||||
downloadItemPart: DownloadItemPart
|
||||
) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
||||
)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// fAILED
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
|
||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
||||
/** Moves the downloaded file to its final destination. */
|
||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb =
|
||||
object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
|
||||
override fun onCompleted(result: Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(
|
||||
tag,
|
||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
||||
)
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
||||
) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile =
|
||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// Failed
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription =
|
||||
FileDescription(
|
||||
downloadItemPart.filename,
|
||||
downloadItemPart.finalDestinationSubfolder,
|
||||
mimetype
|
||||
)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a download item is finished and processes it. */
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
||||
)
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
val jsobj =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
put(
|
||||
"localLibraryItem",
|
||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
||||
)
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
put(
|
||||
"localMediaProgress",
|
||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
|
|
|
@ -1,60 +1,95 @@
|
|||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
||||
import okhttp3.*
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
|
||||
/**
|
||||
* 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(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun download(url:String) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "download URL $url FAILED")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val responseBody: ResponseBody = response.body
|
||||
?: throw IllegalStateException("Response doesn't contain a file")
|
||||
|
||||
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
})
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.body?.let { responseBody ->
|
||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
?: run {
|
||||
Log.e(tag, "Response doesn't contain a file")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
||||
AutoCloseable {
|
||||
private val outputStream: OutputStream
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
|
||||
init {
|
||||
this.outputStream = outputStream
|
||||
this.progressCallback = progressCallback
|
||||
}
|
||||
/**
|
||||
* Writes binary data to an output stream.
|
||||
*
|
||||
* @property outputStream The output stream to write the data to.
|
||||
* @property progressCallback The callback to report write progress.
|
||||
*/
|
||||
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 {
|
||||
fun write(inputStream: InputStream, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var readBytes: Int
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes.toLong()
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
|
@ -63,12 +98,17 @@ class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadIte
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 1024
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class SecureStorage(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "SecureStorage"
|
||||
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val IV_LENGTH = 12
|
||||
private const val TAG_LENGTH = 128
|
||||
}
|
||||
|
||||
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and stores a refresh token for a specific server connection
|
||||
*/
|
||||
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||
return try {
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
|
||||
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||
val combined = cipher.iv + encryptedBytes
|
||||
|
||||
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||
|
||||
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts a refresh token for a specific server connection
|
||||
*/
|
||||
fun getRefreshToken(serverConnectionId: String): String? {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||
|
||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
String(decryptedBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a refresh token for a specific server connection
|
||||
*/
|
||||
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a refresh token exists for a specific server connection
|
||||
*/
|
||||
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
} else {
|
||||
createKey()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKey(): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(keyGenSpec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
|
@ -1,133 +1,221 @@
|
|||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.media.MediaPlayer
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||
import java.text.SimpleDateFormat
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||
|
||||
class SleepTimerManager
|
||||
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
private var sleepTimerTask: TimerTask? = null
|
||||
private var sleepTimerRunning: Boolean = false
|
||||
private var sleepTimerEndTime: Long = 0L
|
||||
private var sleepTimerLength: Long = 0L
|
||||
private var sleepTimerElapsed: Long = 0L
|
||||
private var sleepTimerFinishedAt: Long = 0L
|
||||
private var isAutoSleepTimer: Boolean = false // When timer was auto-set
|
||||
private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period
|
||||
private var sleepTimerSessionId: String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
/**
|
||||
* Gets the current time from the player notification service.
|
||||
* @return Long - the current time in milliseconds.
|
||||
*/
|
||||
private fun getCurrentTime(): Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
/**
|
||||
* Gets the duration of the current playback.
|
||||
* @return Long - the duration in milliseconds.
|
||||
*/
|
||||
private fun getDuration(): Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
/**
|
||||
* Checks if the player is currently playing.
|
||||
* @return Boolean - true if the player is playing, false otherwise.
|
||||
*/
|
||||
private fun getIsPlaying(): Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
/**
|
||||
* Gets the playback speed of the player.
|
||||
* @return Float - the playback speed.
|
||||
*/
|
||||
private fun getPlaybackSpeed(): Float {
|
||||
return playerNotificationService.currentPlayer.playbackParameters.speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the volume of the player.
|
||||
* @param volume Float - the volume level to set.
|
||||
*/
|
||||
private fun setVolume(volume: Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
/** Pauses the player. */
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
/** Plays the player. */
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
/**
|
||||
* Gets the remaining time of the sleep timer in seconds.
|
||||
* @param speed Float - the playback speed of the player, default value is 1.
|
||||
* @return Int - the remaining time in seconds.
|
||||
*/
|
||||
private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int {
|
||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||
}
|
||||
// For chapter end timer
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt()
|
||||
}
|
||||
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
/**
|
||||
* Sets the sleep timer.
|
||||
* @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
private fun setSleepTimer(time: Long): Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
if (time == 0L) {
|
||||
// Get the current chapter time and set the sleep timer to the end of the chapter
|
||||
val chapterEndTime = this.getChapterEndTime()
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
if (chapterEndTime == null) {
|
||||
Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (currentTime > chapterEndTime) {
|
||||
Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime")
|
||||
return false
|
||||
}
|
||||
|
||||
sleepTimerEndTime = chapterEndTime
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
// Set sleep timer length. Will be 0L if using chapter end time
|
||||
sleepTimerLength = time
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
sleepTimerTask =
|
||||
Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
val sleepTimeSecondsRemaining =
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||
Log.d(
|
||||
tag,
|
||||
"Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s"
|
||||
)
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
sleepTimeSecondsRemaining,
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||
playChimeSound()
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(
|
||||
getCurrentTime()
|
||||
)
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 &&
|
||||
DeviceManager.deviceData
|
||||
.deviceSettings
|
||||
?.disableSleepTimerFadeOut != true
|
||||
) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(
|
||||
tag,
|
||||
"SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining"
|
||||
)
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
/**
|
||||
* Sets a manual sleep timer.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
* @param time Long - the time to set the sleep timer for.
|
||||
* @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
if (isChapterTime) {
|
||||
Log.d(tag, "Setting manual sleep timer for end of chapter")
|
||||
return setSleepTimer(0L)
|
||||
} else {
|
||||
Log.d(tag, "Setting manual sleep timer for $time")
|
||||
return setSleepTimer(time)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the sleep timer. */
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
|
@ -138,32 +226,36 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
/**
|
||||
* Gets the sleep timer end time.
|
||||
* @return Long - the sleep timer end time in milliseconds.
|
||||
*/
|
||||
fun getSleepTimerTime(): Long {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
/** Cancels the sleep timer. */
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
|
||||
if (isAutoSleepTimer) {
|
||||
Log.i(tag, "Disabling auto sleep timer")
|
||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.i(tag, "Disabling auto sleep timer for this time period")
|
||||
autoTimerDisabled = true
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when resetting sleep timer
|
||||
/** Provides vibration feedback when resetting the sleep timer. */
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
val vibrator: Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager =
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibrator = vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
|
@ -172,18 +264,32 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
|
||||
vibrator.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1)
|
||||
it.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
it.vibrate(10)
|
||||
@Suppress("DEPRECATION") it.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the chapter end time for use in End of Chapter timers
|
||||
// if less than 2s remain in chapter then use the next chapter
|
||||
private fun getChapterEndTime():Long? {
|
||||
/** Plays chime sound */
|
||||
private fun playChimeSound() {
|
||||
AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound")
|
||||
val ctx = playerNotificationService.getContext()
|
||||
val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell)
|
||||
mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME)
|
||||
mediaPlayer.start()
|
||||
mediaPlayer.setOnCompletionListener {
|
||||
mediaPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||
* the chapter, then use the next chapter.
|
||||
* @return Long? - the chapter end time in milliseconds, or null if there is no current session.
|
||||
*/
|
||||
private fun getChapterEndTime(): Long? {
|
||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||
if (currentChapterEndTimeMs == null) {
|
||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||
|
@ -191,11 +297,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
}
|
||||
|
||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||
return if (timeLeftInChapter < 2000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
||||
// If less than 10 seconds remain in the chapter, set the timer to the next chapter or track
|
||||
// This handles the auto-rewind from not playing media for a little bit to select the next
|
||||
// chapter
|
||||
return if (timeLeftInChapter < 10000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining")
|
||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
||||
Log.e(
|
||||
tag,
|
||||
"Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs"
|
||||
)
|
||||
null
|
||||
} else {
|
||||
nextChapterEndTimeMs
|
||||
|
@ -205,17 +317,35 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
}
|
||||
}
|
||||
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
/**
|
||||
* Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does
|
||||
* not take place, make sure to set `isAutoSleepTimer` after calling this function.
|
||||
*/
|
||||
private fun tryRewindAutoSleepTimer() {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) {
|
||||
Log.i(
|
||||
tag,
|
||||
"Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms"
|
||||
)
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if the sleep timer should be reset. */
|
||||
private fun checkShouldResetSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerRunning) {
|
||||
// Reset the sleep timer if it has been running for at least 3 seconds or it is an end of
|
||||
// chapter/track timer
|
||||
if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
} else {
|
||||
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
|
@ -226,51 +356,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
// If timer was cleared by going negative on time, clear the sleep timer length so pressing
|
||||
// play allows playback to continue without the sleep timer continuously setting for 1 second.
|
||||
if (sleepTimerLength == 1000L) {
|
||||
Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically rewind in the book if settings are enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
Log.d(tag, "Resetting stopped sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2
|
||||
* minute grace period after the timer ends or while media is playing.
|
||||
*/
|
||||
fun handleShake() {
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||
if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
|
@ -279,48 +389,63 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increases the sleep timer time.
|
||||
* @param time Long - the time to increase the sleep timer by.
|
||||
*/
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
// Increase the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go over the duration of the current playback item
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
// Fixed length
|
||||
sleepTimerLength += time
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime())
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration())
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the sleep timer time.
|
||||
* @param time Long - the time to decrease the sleep timer by.
|
||||
*/
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
|
||||
// Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go below 1 second
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength -= time
|
||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||
// Fixed length
|
||||
sleepTimerLength = maxOf(sleepTimerLength - time, 1000L)
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
maxOf(
|
||||
sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(),
|
||||
getCurrentTime() + 1000
|
||||
)
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */
|
||||
fun checkAutoSleepTimer() {
|
||||
if (sleepTimerRunning) { // Sleep timer already running
|
||||
return
|
||||
|
@ -337,10 +462,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// In cases where end time is before start time then we shift the time window forward or
|
||||
// backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the
|
||||
// previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the
|
||||
// next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
|
@ -349,47 +477,52 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
|||
}
|
||||
}
|
||||
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
val isDuringAutoTime =
|
||||
currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
val chapterEndTimeMs = this.getChapterEndTime()
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
// Determine whether to set the auto sleep timer or not
|
||||
if (autoTimerDisabled) {
|
||||
if (!isDuringAutoTime) {
|
||||
// Check if sleep timer was disabled during the previous period and enable again
|
||||
Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period")
|
||||
autoTimerDisabled = false
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
// Auto time is disabled, do not set sleep timer
|
||||
Log.i(tag, "Auto sleep timer is disabled for this time period")
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
if (isDuringAutoTime) {
|
||||
// Start an auto sleep timer
|
||||
val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||
val currentMin = currentCalendar.get(Calendar.MINUTE)
|
||||
Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin")
|
||||
|
||||
// Automatically rewind in the book if settings is enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set `isAutoSleepTimer` to true to indicate that the timer was set automatically
|
||||
// and to not cause the timer to rewind
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength)
|
||||
} else {
|
||||
Log.d(tag, "Not in auto sleep time period")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
/**
|
||||
* Handles the media play event and checks if the sleep timer should be reset or set.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
*/
|
||||
fun handleMediaPlayEvent(playbackSessionId: String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer
|
||||
// will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
} else {}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
|
|
|
@ -36,76 +36,97 @@ object MediaEventManager {
|
|||
}
|
||||
|
||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}"
|
||||
)
|
||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}"
|
||||
)
|
||||
addSyncEvent("Sync", mediaProgress, description)
|
||||
}
|
||||
|
||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
||||
private fun addSyncEvent(
|
||||
eventName: String,
|
||||
mediaProgress: MediaProgressWrapper,
|
||||
description: String
|
||||
) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||
if (mediaItemHistory == null) {
|
||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
||||
Log.w(
|
||||
tag,
|
||||
"addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
private fun addPlaybackEvent(
|
||||
eventName: String,
|
||||
playbackSession: PlaybackSession,
|
||||
syncResult: SyncResult?
|
||||
) {
|
||||
val mediaItemHistory =
|
||||
getMediaItemHistoryMediaItem(playbackSession.mediaItemId)
|
||||
?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? {
|
||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||
}
|
||||
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory {
|
||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
||||
val libraryItemId = playbackSession.libraryItemId ?: ""
|
||||
val episodeId: String? = playbackSession.episodeId
|
||||
return MediaItemHistory(
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf())
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
false, // local-only items are not supported
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,18 +15,29 @@ import org.json.JSONException
|
|||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val tag = "MediaManager"
|
||||
|
||||
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||
private var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||
private var selectedLibraryId = ""
|
||||
|
||||
private var cachedLibraryAuthors : MutableMap<String, MutableMap<String, LibraryAuthorItem>> = hashMapOf()
|
||||
private var cachedLibraryAuthorItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||
private var cachedLibraryAuthorSeriesItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||
private var cachedLibrarySeries : MutableMap<String, List<LibrarySeriesItem>> = hashMapOf()
|
||||
private var cachedLibrarySeriesItem : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
|
||||
private var cachedLibraryCollections : MutableMap<String, MutableMap<String, LibraryCollection>> = hashMapOf()
|
||||
private var cachedLibraryRecentShelves : MutableMap<String, MutableList<LibraryShelfType>> = hashMapOf()
|
||||
private var cachedLibraryDiscovery : MutableMap<String, MutableList<LibraryItem>> = hashMapOf()
|
||||
private var cachedLibraryPodcasts : MutableMap<String, MutableMap<String, LibraryItem>> = hashMapOf()
|
||||
private var isLibraryPodcastsCached : MutableMap<String, Boolean> = hashMapOf()
|
||||
var allLibraryPersonalizationsDone : Boolean = false
|
||||
private var libraryPersonalizationsDone : Int = 0
|
||||
|
||||
private var selectedPodcast:Podcast? = null
|
||||
private var selectedLibraryItemId:String? = null
|
||||
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||
private var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
private var serverConfigIdUsed:String? = null
|
||||
private var serverConfigLastPing:Long = 0L
|
||||
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
|
@ -39,6 +50,35 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
return serverLibraries.find { it.id == id } != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is discovery shelf for [libraryId]
|
||||
* If personalized shelves are not yet populated for library then populate
|
||||
*
|
||||
*/
|
||||
fun getHasDiscovery(libraryId: String) : Boolean {
|
||||
if (cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||
if (cachedLibraryDiscovery[libraryId]!!.isNotEmpty()) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
populatePersonalizedDataForLibrary(libraryId){}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getLibrary(id:String) : Library? {
|
||||
return serverLibraries.find { it.id == id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [libraryItem] to [serverLibraryItems] if it is not already added
|
||||
*/
|
||||
private fun addServerLibrary(libraryItem: LibraryItem) {
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSavedPlaybackRate():Float {
|
||||
if (userSettingsPlaybackRate != null) {
|
||||
return userSettingsPlaybackRate ?: 1f
|
||||
|
@ -91,19 +131,31 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun checkResetServerItems() {
|
||||
fun checkResetServerItems():Boolean {
|
||||
// When opening android auto need to check if still connected to server
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
selectedLibraryItems = mutableListOf()
|
||||
selectedLibraryId = ""
|
||||
cachedLibraryAuthors = hashMapOf()
|
||||
cachedLibraryAuthorItems = hashMapOf()
|
||||
cachedLibraryAuthorSeriesItems = hashMapOf()
|
||||
cachedLibrarySeries = hashMapOf()
|
||||
cachedLibrarySeriesItem = hashMapOf()
|
||||
cachedLibraryCollections = hashMapOf()
|
||||
cachedLibraryRecentShelves = hashMapOf()
|
||||
cachedLibraryDiscovery = hashMapOf()
|
||||
cachedLibraryPodcasts = hashMapOf()
|
||||
isLibraryPodcastsCached = hashMapOf()
|
||||
serverItemsInProgress = listOf()
|
||||
allLibraryPersonalizationsDone = false
|
||||
libraryPersonalizationsDone = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||
|
@ -120,28 +172,457 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||
cb(selectedLibraryItems)
|
||||
/**
|
||||
* Load personalized shelves from server for all libraries.
|
||||
* [cb] resolves when all libraries are processed
|
||||
*/
|
||||
fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) {
|
||||
val remaining = AtomicInteger(serverLibraries.size)
|
||||
|
||||
serverLibraries.forEach { lib ->
|
||||
Log.d(tag, "Loading personalization for library ${lib.name}")
|
||||
populatePersonalizedDataForLibrary(lib.id) {
|
||||
Log.d(tag, "Loaded personalization for library ${lib.name}")
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
Log.d(tag, "Finished loading all library personalization data")
|
||||
allLibraryPersonalizationsDone = true
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get personalized shelves from server for selected [libraryId].
|
||||
* Populates [cachedLibraryRecentShelves] and [cachedLibraryDiscovery].
|
||||
*/
|
||||
private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) {
|
||||
apiHandler.getLibraryPersonalized(libraryId) { shelves ->
|
||||
Log.d(tag, "populatePersonalizedDataForLibrary $libraryId")
|
||||
if (shelves === null) return@getLibraryPersonalized
|
||||
shelves.map { shelf ->
|
||||
Log.d(tag, "$shelf")
|
||||
if (shelf.type == "book") {
|
||||
if (shelf.id == "continue-listening") return@map
|
||||
else if (shelf.id == "listen-again") return@map
|
||||
else if (shelf.id == "recently-added") {
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||
}
|
||||
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||
}
|
||||
}
|
||||
else if (shelf.id == "discover") {
|
||||
if (!cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||
cachedLibraryDiscovery[libraryId] = mutableListOf()
|
||||
}
|
||||
(shelf as LibraryShelfBookEntity).entities?.map {
|
||||
cachedLibraryDiscovery[libraryId]!!.add(it)
|
||||
}
|
||||
}
|
||||
else if (shelf.id == "continue-reading") return@map
|
||||
else if (shelf.id == "continue-series") return@map
|
||||
shelf as LibraryShelfBookEntity
|
||||
} else if (shelf.type == "series") {
|
||||
if (shelf.id == "recent-series") {
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||
}
|
||||
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||
}
|
||||
}
|
||||
} else if (shelf.type == "episode") {
|
||||
if (shelf.id == "continue-listening") return@map
|
||||
else if (shelf.id == "listen-again") return@map
|
||||
else if (shelf.id == "newest-episodes") {
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||
}
|
||||
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||
}
|
||||
|
||||
val podcastLibraryItemIds = mutableListOf<String>()
|
||||
(shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem ->
|
||||
if (!podcastLibraryItemIds.contains(libraryItem.id)) {
|
||||
podcastLibraryItemIds.add(libraryItem.id)
|
||||
loadPodcastItem(libraryItem.libraryId, libraryItem.id) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (shelf.type == "podcast") {
|
||||
if (shelf.id == "recently-added"){
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||
}
|
||||
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||
}
|
||||
}
|
||||
else if (shelf.id == "discover"){
|
||||
return@map
|
||||
}
|
||||
} else if (shelf.type =="authors") {
|
||||
if (shelf.id == "newest-authors") {
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cachedLibraryRecentShelves[libraryId] = mutableListOf()
|
||||
}
|
||||
if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) {
|
||||
cachedLibraryRecentShelves[libraryId]!!.add(shelf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns podcasts for selected library.
|
||||
* If data is not found from local cache it is loaded from server
|
||||
*/
|
||||
fun loadLibraryPodcasts(libraryId:String, cb: (List<LibraryItem>?) -> Unit) {
|
||||
// Without this there is possibility that only recent podcasts get loaded
|
||||
// Loading recent podcasts will also create cachedLibraryPodcasts entry for library
|
||||
if (!isLibraryPodcastsCached.containsKey(libraryId)) {
|
||||
isLibraryPodcastsCached[libraryId] = false
|
||||
}
|
||||
// Ensure that there is map for library
|
||||
if (!cachedLibraryPodcasts.containsKey(libraryId)) {
|
||||
cachedLibraryPodcasts[libraryId] = mutableMapOf()
|
||||
}
|
||||
if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) {
|
||||
Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId")
|
||||
cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title })
|
||||
} else {
|
||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
if (libraryItemsWithAudio.isNotEmpty()) {
|
||||
selectedLibraryId = libraryId
|
||||
}
|
||||
|
||||
selectedLibraryItems = mutableListOf()
|
||||
libraryItemsWithAudio.forEach { libraryItem ->
|
||||
selectedLibraryItems.add(libraryItem)
|
||||
cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem)
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
isLibraryPodcastsCached[libraryId] = true
|
||||
Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId")
|
||||
cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns series with audio books from selected library.
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibrarySeriesWithAudio(libraryId:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||
// Check "cache" first
|
||||
if (cachedLibrarySeries.containsKey(libraryId)) {
|
||||
Log.d(tag, "Series with audio found from cache | Library $libraryId ")
|
||||
cb(cachedLibrarySeries[libraryId] as List<LibrarySeriesItem>)
|
||||
} else {
|
||||
apiHandler.getLibrarySeries(libraryId) { seriesItems ->
|
||||
Log.d(tag, "Series with audio loaded from server | Library $libraryId")
|
||||
val seriesItemsWithAudio = seriesItems.filter { si -> si.audiobookCount > 0 }
|
||||
|
||||
cachedLibrarySeries[libraryId] = seriesItemsWithAudio
|
||||
|
||||
cb(seriesItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns series with audiobooks from selected library using filter for paging.
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibrarySeriesWithAudio(libraryId:String, seriesFilter:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||
// Check "cache" first
|
||||
if (!cachedLibrarySeries.containsKey(libraryId)) {
|
||||
loadLibrarySeriesWithAudio(libraryId) {}
|
||||
} else {
|
||||
Log.d(tag, "Series with audio found from cache | Library $libraryId ")
|
||||
}
|
||||
val seriesWithBooks = cachedLibrarySeries[libraryId]!!.filter { ls -> ls.title.uppercase().startsWith(seriesFilter) }.toList()
|
||||
cb(seriesWithBooks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts books in series. Assumes that sequence is main.minor
|
||||
*/
|
||||
private fun sortSeriesBooks(seriesBooks: List<LibraryItem>) : List<LibraryItem> {
|
||||
val sortingLogic = compareBy<LibraryItem> { it.seriesSequenceParts[0].length }
|
||||
.thenBy { it.seriesSequenceParts[0].ifEmpty { "" } }
|
||||
.thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length }
|
||||
.thenBy { it.seriesSequenceParts.getOrElse(1) { "" } }
|
||||
return seriesBooks.sortedWith(sortingLogic)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns books for series from library.
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
// Check "cache" first
|
||||
if (!cachedLibrarySeriesItem.containsKey(libraryId)) {
|
||||
cachedLibrarySeriesItem[libraryId] = hashMapOf()
|
||||
}
|
||||
if (cachedLibrarySeriesItem[libraryId]!!.containsKey(seriesId)) {
|
||||
Log.d(tag, "Items for series $seriesId found from cache | Library $libraryId")
|
||||
cachedLibrarySeriesItem[libraryId]!![seriesId]?.let { cb(it) }
|
||||
} else {
|
||||
apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems ->
|
||||
Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId")
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
|
||||
val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio)
|
||||
cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio
|
||||
|
||||
sortedLibraryItemsWithAudio.forEach { libraryItem ->
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
cb(sortedLibraryItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns authors with books from library.
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadAuthorsWithBooks(libraryId:String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||
// Check "cache" first
|
||||
if (cachedLibraryAuthors.containsKey(libraryId)) {
|
||||
Log.d(tag, "Authors with books found from cache | Library $libraryId ")
|
||||
cb(cachedLibraryAuthors[libraryId]!!.values.toList())
|
||||
} else {
|
||||
// Fetch data from server and add it to local "cache"
|
||||
apiHandler.getLibraryAuthors(libraryId) { authorItems ->
|
||||
Log.d(tag, "Authors with books loaded from server | Library $libraryId ")
|
||||
// TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so
|
||||
var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 }
|
||||
authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name }
|
||||
// Ensure that there is map for library
|
||||
cachedLibraryAuthors[libraryId] = mutableMapOf()
|
||||
// Cache authors
|
||||
authorItemsWithBooks.forEach {
|
||||
if (!cachedLibraryAuthors[libraryId]!!.containsKey(it.id)) {
|
||||
cachedLibraryAuthors[libraryId]!![it.id] = it
|
||||
}
|
||||
}
|
||||
cb(authorItemsWithBooks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns authors with books from selected library using filter for paging.
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadAuthorsWithBooks(libraryId:String, authorFilter: String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||
// Check "cache" first
|
||||
if (cachedLibraryAuthors.containsKey(libraryId)) {
|
||||
Log.d(tag, "Authors with books found from cache | Library $libraryId ")
|
||||
} else {
|
||||
loadAuthorsWithBooks(libraryId) {}
|
||||
}
|
||||
val authorsWithBooks = cachedLibraryAuthors[libraryId]!!.values.filter { lai -> lai.name.uppercase().startsWith(authorFilter) }.toList()
|
||||
cb(authorsWithBooks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns audiobooks for author from library
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadAuthorBooksWithAudio(libraryId:String, authorId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
// Ensure that there is map for library
|
||||
if (!cachedLibraryAuthorItems.containsKey(libraryId)) {
|
||||
cachedLibraryAuthorItems[libraryId] = mutableMapOf()
|
||||
}
|
||||
// Check "cache" first
|
||||
if (cachedLibraryAuthorItems[libraryId]!!.containsKey(authorId)) {
|
||||
Log.d(tag, "Items for author $authorId found from cache | Library $libraryId")
|
||||
cachedLibraryAuthorItems[libraryId]!![authorId]?.let { cb(it) }
|
||||
} else {
|
||||
apiHandler.getLibraryItemsFromAuthor(libraryId, authorId) { libraryItems ->
|
||||
Log.d(tag, "Items for author $authorId loaded from server | Library $libraryId")
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
|
||||
cachedLibraryAuthorItems[libraryId]!![authorId] = libraryItemsWithAudio
|
||||
|
||||
libraryItemsWithAudio.forEach { libraryItem ->
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
cb(libraryItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns audiobooks for author from specified series within library
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadAuthorSeriesBooksWithAudio(libraryId:String, authorId:String, seriesId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||
val authorSeriesKey = "$authorId|$seriesId"
|
||||
// Ensure that there is map for library
|
||||
if (!cachedLibraryAuthorSeriesItems.containsKey(libraryId)) {
|
||||
cachedLibraryAuthorSeriesItems[libraryId] = mutableMapOf()
|
||||
}
|
||||
// Check "cache" first
|
||||
if (cachedLibraryAuthorSeriesItems[libraryId]!!.containsKey(authorSeriesKey)) {
|
||||
Log.d(tag, "Items for series $seriesId with author $authorId found from cache | Library $libraryId")
|
||||
cachedLibraryAuthorSeriesItems[libraryId]!![authorSeriesKey]?.let { cb(it) }
|
||||
} else {
|
||||
apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems ->
|
||||
Log.d(tag, "Items for series $seriesId with author $authorId loaded from server | Library $libraryId")
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
if (!cachedLibraryAuthors[libraryId]!!.containsKey(authorId)) {
|
||||
Log.d(tag, "Author data is missing")
|
||||
}
|
||||
val authorName = cachedLibraryAuthors[libraryId]!![authorId]?.name ?: ""
|
||||
Log.d(tag, "Using author name: $authorName")
|
||||
val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 }
|
||||
|
||||
val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio)
|
||||
cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio
|
||||
|
||||
sortedLibraryItemsWithAudio.forEach { libraryItem ->
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
|
||||
cb(sortedLibraryItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns collections with audiobooks from library
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibraryCollectionsWithAudio(libraryId:String, cb: (List<LibraryCollection>) -> Unit) {
|
||||
if (cachedLibraryCollections.containsKey(libraryId)) {
|
||||
Log.d(tag, "Collections with books found from cache | Library $libraryId ")
|
||||
cb(cachedLibraryCollections[libraryId]!!.values.toList())
|
||||
} else {
|
||||
apiHandler.getLibraryCollections(libraryId) { libraryCollections ->
|
||||
Log.d(tag, "Collections with books loaded from server | Library $libraryId ")
|
||||
val libraryCollectionsWithAudio = libraryCollections.filter { lc -> lc.audiobookCount > 0 }
|
||||
|
||||
// Cache collections
|
||||
cachedLibraryCollections[libraryId] = hashMapOf()
|
||||
libraryCollectionsWithAudio.forEach {
|
||||
if (!cachedLibraryCollections[libraryId]!!.containsKey(it.id)) {
|
||||
cachedLibraryCollections[libraryId]!![it.id] = it
|
||||
}
|
||||
}
|
||||
cb(libraryCollectionsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns audiobooks for collection from library
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibraryCollectionBooksWithAudio(libraryId: String, collectionId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (!cachedLibraryCollections.containsKey(libraryId)) {
|
||||
loadLibraryCollectionsWithAudio(libraryId) {}
|
||||
}
|
||||
Log.d(tag, "Trying to find collection $collectionId items from from cache | Library $libraryId ")
|
||||
if ( cachedLibraryCollections[libraryId]!!.containsKey(collectionId)) {
|
||||
val libraryCollectionBookswithAudio = cachedLibraryCollections[libraryId]!![collectionId]?.books
|
||||
libraryCollectionBookswithAudio?.forEach { libraryItem ->
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
cb(libraryCollectionBookswithAudio as List<LibraryItem>)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns audiobooks from discovery shelf for [libraryId]
|
||||
* If data is not found from local cache then it will be fetched from server
|
||||
*/
|
||||
fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (!cachedLibraryDiscovery.containsKey(libraryId)) {
|
||||
cb(listOf())
|
||||
}
|
||||
val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() }
|
||||
libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) }
|
||||
cb(libraryItemsWithAudio as List<LibraryItem>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns recent shelves for [libraryId]
|
||||
* If data is not shelves are found returns empty list
|
||||
*/
|
||||
fun getLibraryRecentShelfs(libraryId: String, cb: (List<LibraryShelfType>) -> Unit) {
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId")
|
||||
cb(listOf())
|
||||
return
|
||||
}
|
||||
cb(cachedLibraryRecentShelves[libraryId] as List<LibraryShelfType>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns recent shelf by [type] for [libraryId]
|
||||
* If shelf is not found returns null
|
||||
*/
|
||||
fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) {
|
||||
Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type")
|
||||
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
|
||||
cb(null)
|
||||
return
|
||||
}
|
||||
for (shelf in cachedLibraryRecentShelves[libraryId]!!) {
|
||||
if (shelf.type == type.lowercase()) {
|
||||
cb(shelf)
|
||||
return
|
||||
}
|
||||
}
|
||||
cb(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads podcasts for newest episodes shelf
|
||||
*/
|
||||
private fun loadPodcastItem(libraryId: String, libraryItemId: String, cb: (LibraryItem?) -> Unit) {
|
||||
// Ensure that there is map for library
|
||||
if (!cachedLibraryPodcasts.containsKey(libraryId)) {
|
||||
cachedLibraryPodcasts[libraryId] = mutableMapOf()
|
||||
}
|
||||
if (cachedLibraryPodcasts[libraryId]!!.containsKey(libraryItemId)) {
|
||||
Log.d(tag, "loadPodcastItem: Podcast found from cache | Library $libraryItemId ")
|
||||
cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId))
|
||||
} else {
|
||||
Log.d(tag, "loadPodcastItem: Calling getLibraryItem $libraryItemId")
|
||||
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||
if (libraryItem !== null) {
|
||||
Log.d(tag, "loadPodcastItem: Got library item ${libraryItem.id} ${libraryItem.media.metadata.title}")
|
||||
val podcast = libraryItem.media as Podcast
|
||||
podcast.episodes?.forEach { podcastEpisode ->
|
||||
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItem, podcastEpisode)
|
||||
}
|
||||
cachedLibraryPodcasts[libraryId]?.set(libraryItemId, libraryItem)
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||
if (libraryItemId.startsWith("local")) {
|
||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||
|
@ -187,8 +668,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
val episodes = podcast.episodes?.sortedByDescending { it.publishedAt }
|
||||
val children = episodes?.map { podcastEpisode ->
|
||||
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
|
||||
|
@ -209,13 +690,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads libraries for selected server with stats
|
||||
*/
|
||||
private fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||
if (serverLibraries.isNotEmpty()) {
|
||||
cb(serverLibraries)
|
||||
} else {
|
||||
apiHandler.getLibraries {
|
||||
serverLibraries = it
|
||||
cb(it)
|
||||
apiHandler.getLibraries { loadedLibraries ->
|
||||
serverLibraries = loadedLibraries
|
||||
cb(serverLibraries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -329,6 +813,25 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun initializeInProgressItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Initializing inprogress items")
|
||||
|
||||
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||
itemsInProgress.forEach {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
|
||||
if (it.episode != null) {
|
||||
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||
}
|
||||
}
|
||||
Log.d(tag, "Initializing inprogress items done")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
|
@ -343,23 +846,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
Log.w(tag, "No libraries returned from server request")
|
||||
cb()
|
||||
} else {
|
||||
val library = libraries[0]
|
||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||
|
||||
loadItemsInProgressForAllLibraries { itemsInProgress ->
|
||||
itemsInProgress.forEach {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
|
||||
if (it.episode != null) {
|
||||
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
|
||||
}
|
||||
}
|
||||
|
||||
cb() // Fully loaded
|
||||
}
|
||||
cb() // Fully loaded
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
|
@ -369,6 +856,65 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles search requests.
|
||||
* Searches from books, series and authors
|
||||
*/
|
||||
suspend fun doSearch(libraryId: String, queryString: String) : Map<String, List<MediaBrowserCompat.MediaItem>> {
|
||||
return suspendCoroutine {
|
||||
apiHandler.getSearchResults(libraryId, queryString) { searchResult ->
|
||||
Log.d(tag, "searchLocalCache: $searchResult")
|
||||
// Nothing found from server
|
||||
if (searchResult === null) {
|
||||
it.resume(mapOf())
|
||||
return@getSearchResults
|
||||
}
|
||||
|
||||
val foundItems: MutableMap<String, List<MediaBrowserCompat.MediaItem>> = mutableMapOf()
|
||||
|
||||
val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId }
|
||||
|
||||
// Books
|
||||
if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) {
|
||||
Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books")
|
||||
val children = searchResult.book!!.filter { it.libraryItem.checkHasTracks() }.map { bookResult ->
|
||||
val libraryItem = bookResult.libraryItem
|
||||
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||
val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})")
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
foundItems["book"] = children
|
||||
}
|
||||
if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) {
|
||||
Log.d(tag, "onSearch: found ${searchResult.series!!.size} series")
|
||||
val children = searchResult.series!!.map { seriesResult ->
|
||||
val seriesItem = seriesResult.series
|
||||
seriesItem.books = seriesResult.books as MutableList<LibraryItem>
|
||||
val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})")
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
foundItems["series"] = children
|
||||
}
|
||||
if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) {
|
||||
Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors")
|
||||
val children = searchResult.authors!!.map { authorItem ->
|
||||
val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})")
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
foundItems["authors"] = children
|
||||
}
|
||||
|
||||
it.resume(foundItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFirstItem() : LibraryItemWrapper? {
|
||||
if (serverLibraryItems.isNotEmpty()) {
|
||||
return serverLibraryItems[0]
|
||||
|
|
|
@ -8,41 +8,49 @@ import com.audiobookshelf.app.data.MediaProgress
|
|||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
data class MediaProgressSyncData(
|
||||
var timeListened:Long, // seconds
|
||||
var duration:Double, // seconds
|
||||
var currentTime:Double // seconds
|
||||
var timeListened: Long, // seconds
|
||||
var duration: Double, // seconds
|
||||
var currentTime: Double // seconds
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
var serverSyncAttempted:Boolean,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?
|
||||
var serverSyncAttempted: Boolean,
|
||||
var serverSyncSuccess: Boolean?,
|
||||
var serverSyncMessage: String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
class MediaProgressSyncer(
|
||||
val playerNotificationService: PlayerNotificationService,
|
||||
private val apiHandler: ApiHandler
|
||||
) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
var listeningTimerRunning: Boolean = false
|
||||
|
||||
private var lastSyncTime:Long = 0
|
||||
private var failedSyncs:Int = 0
|
||||
private var lastSyncTime: Long = 0
|
||||
private var failedSyncs: Int = 0
|
||||
|
||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
private val currentDisplayTitle
|
||||
get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal
|
||||
get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId
|
||||
get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration
|
||||
get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
fun start(playbackSession: PlaybackSession) {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||
if (playbackSession.id != currentSessionId) {
|
||||
|
@ -62,40 +70,48 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}"
|
||||
)
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
listeningTimerTask =
|
||||
Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if
|
||||
// last sync time is >= 60s
|
||||
val shouldSyncServer =
|
||||
PlayerNotificationService.isUnmeteredNetwork ||
|
||||
System.currentTimeMillis() - lastSyncTime >=
|
||||
METERED_CONNECTION_SYNC_INTERVAL
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(playbackSession:PlaybackSession) {
|
||||
fun play(playbackSession: PlaybackSession) {
|
||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||
MediaEventManager.playEvent(playbackSession)
|
||||
|
||||
start(playbackSession)
|
||||
}
|
||||
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
fun stop(shouldSync: Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) {
|
||||
reset()
|
||||
return cb()
|
||||
|
@ -106,7 +122,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
val currentTime =
|
||||
if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) { syncResult ->
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
|
@ -192,17 +209,21 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||
}
|
||||
|
||||
// Currently unused
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
currentPlaybackSession?.let {
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
MediaEventManager.syncEvent(
|
||||
mediaProgress,
|
||||
"Received from server get media progress request while playback session open"
|
||||
)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||
fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) {
|
||||
if (lastSyncTime <= 0) {
|
||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||
return cb(null)
|
||||
|
@ -214,11 +235,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime)
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
|
@ -226,11 +250,7 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
|
||||
// Save playback session to db (server linked sessions only)
|
||||
// Sessions are removed once successfully synced with the server
|
||||
currentPlaybackSession?.let {
|
||||
if (!it.isLocalLibraryItemOnly) {
|
||||
DeviceManager.dbManager.savePlaybackSession(it)
|
||||
}
|
||||
}
|
||||
currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) }
|
||||
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
|
@ -238,36 +258,50 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||
)
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
// Send sync to server also if connected to this server and local item belongs to this
|
||||
// server
|
||||
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||
if (hasNetworkConnection &&
|
||||
shouldSyncServer &&
|
||||
!it.libraryItemId.isNullOrEmpty() &&
|
||||
isConnectedToSameServer
|
||||
) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
||||
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)")
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
@ -278,18 +312,20 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
||||
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)")
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||
private fun saveLocalProgress(playbackSession: PlaybackSession) {
|
||||
if (currentLocalMediaProgress == null) {
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
val mediaProgress =
|
||||
DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
if (mediaProgress == null) {
|
||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||
} else {
|
||||
|
@ -306,12 +342,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||
} else {
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||
Log.d(
|
||||
tag,
|
||||
"Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.AnyRes
|
||||
import com.audiobookshelf.app.R
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToAbsIconDrawable(context: Context, absIconName: String): Uri {
|
||||
val drawableId = when(absIconName) {
|
||||
"audiobookshelf" -> R.drawable.abs_audiobookshelf
|
||||
"authors" -> R.drawable.abs_authors
|
||||
"book-1" -> R.drawable.abs_book_1
|
||||
"books-1" -> R.drawable.abs_books_1
|
||||
"books-2" -> R.drawable.abs_books_2
|
||||
"columns" -> R.drawable.abs_columns
|
||||
"database" -> R.drawable.abs_database
|
||||
"file-picture" -> R.drawable.abs_file_picture
|
||||
"headphones" -> R.drawable.abs_headphones
|
||||
"heart" -> R.drawable.abs_heart
|
||||
"microphone_1" -> R.drawable.abs_microphone_1
|
||||
"microphone_2" -> R.drawable.abs_microphone_2
|
||||
"microphone_3" -> R.drawable.abs_microphone_3
|
||||
"music" -> R.drawable.abs_music
|
||||
"podcast" -> R.drawable.abs_podcast
|
||||
"radio" -> R.drawable.abs_radio
|
||||
"rocket" -> R.drawable.abs_rocket
|
||||
"rss" -> R.drawable.abs_rss
|
||||
"star" -> R.drawable.abs_star
|
||||
else -> R.drawable.icon_library_folder
|
||||
}
|
||||
return Uri.parse(
|
||||
ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||
}
|
|
@ -42,7 +42,10 @@ data class DownloadItemPart(
|
|||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
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(
|
||||
|
@ -77,7 +80,7 @@ data class DownloadItemPart(
|
|||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
val serverUrl get() = uri.toString()
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
|
|
|
@ -7,7 +7,6 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -15,7 +14,7 @@ import com.google.android.exoplayer2.Player
|
|||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
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 var currentIconUri: Uri? = null
|
||||
|
@ -36,12 +35,17 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
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) {
|
||||
// Cache the bitmap for the current audiobook so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
|
||||
if (currentIconUri.toString().startsWith("content://")) {
|
||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
|
@ -61,7 +65,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||
}
|
||||
null
|
||||
}
|
||||
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
|
|
|
@ -1,51 +1,45 @@
|
|||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.annotation.AnyRes
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.media.getUriToDrawable
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
itemsInProgress: List<ItemInProgress>,
|
||||
libraries: List<Library>
|
||||
libraries: List<Library>,
|
||||
recentsLoaded: Boolean
|
||||
) {
|
||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||
}
|
||||
|
||||
init {
|
||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||
|
||||
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
}.build()
|
||||
|
||||
val recentMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.md_clock_outline).toString())
|
||||
}.build()
|
||||
|
||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
||||
}.build()
|
||||
|
||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
||||
}.build()
|
||||
|
||||
if (itemsInProgress.isNotEmpty()) {
|
||||
|
@ -53,13 +47,28 @@ class BrowseTree(
|
|||
}
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
if (recentsLoaded) {
|
||||
rootList += recentMetadata
|
||||
}
|
||||
rootList += librariesMetadata
|
||||
|
||||
libraries.forEach { library ->
|
||||
val libraryMediaMetadata = library.getMediaMetadata()
|
||||
// Skip libraries without audio content
|
||||
if (library.stats?.numAudioFiles == 0) return@forEach
|
||||
Log.d("BrowseTree", "Library $library | ${library.icon}")
|
||||
// Generate library list items for Libraries menu
|
||||
val libraryMediaMetadata = library.getMediaMetadata(context)
|
||||
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||
children += libraryMediaMetadata
|
||||
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||
|
||||
if (recentsLoaded) {
|
||||
// Generate library list items for Recent menu
|
||||
val recentlyMediaMetadata = library.getMediaMetadata(context,"recently")
|
||||
val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf()
|
||||
childrenRecently += recentlyMediaMetadata
|
||||
mediaIdToChildren[RECENTLY_ROOT] = childrenRecently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,3 +84,4 @@ const val AUTO_BROWSE_ROOT = "/"
|
|||
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
const val RECENTLY_ROOT = "__RECENTLY__"
|
||||
|
|
|
@ -8,7 +8,6 @@ import android.util.Log
|
|||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.hardware.SensorEvent
|
|||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
|
@ -46,6 +47,7 @@ class ShakeDetector : SensorEventListener {
|
|||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
||||
mShakeCount = 0
|
||||
}
|
||||
AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)")
|
||||
mShakeTimestamp = now
|
||||
mShakeCount++
|
||||
mListener!!.onShake(mShakeCount)
|
||||
|
|
|
@ -180,7 +180,8 @@ class AbsAudioPlayer : Plugin() {
|
|||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
|
@ -198,6 +199,9 @@ class AbsAudioPlayer : Plugin() {
|
|||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
if (!it.hasTracks(episode)) {
|
||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
|
|
|
@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
|||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
@ -22,20 +23,25 @@ class AbsDatabase : Plugin() {
|
|||
val tag = "AbsDatabase"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var apiHandler: ApiHandler
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler: ApiHandler
|
||||
private lateinit var secureStorage: SecureStorage
|
||||
|
||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||
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() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
DeviceManager.dbManager.cleanLogs()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
|
@ -119,7 +125,9 @@ class AbsDatabase : Plugin() {
|
|||
|
||||
val userId = serverConfigPayload.userId
|
||||
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) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
|
@ -128,7 +136,16 @@ class AbsDatabase : Plugin() {
|
|||
// Create new server connection config
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
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
|
||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||
|
@ -136,14 +153,21 @@ class AbsDatabase : Plugin() {
|
|||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
} else {
|
||||
var shouldSave = false
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
|
||||
serverConnectionConfig?.userId = userId
|
||||
serverConnectionConfig?.username = username
|
||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||
serverConnectionConfig?.token = token
|
||||
serverConnectionConfig?.version = serverVersion
|
||||
serverConnectionConfig?.token = accessToken
|
||||
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
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
|
@ -162,6 +186,10 @@ class AbsDatabase : Plugin() {
|
|||
fun removeServerConnectionConfig(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
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>
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
|
@ -174,6 +202,42 @@ class AbsDatabase : Plugin() {
|
|||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken != null) {
|
||||
val result = JSObject()
|
||||
result.put("refreshToken", refreshToken)
|
||||
call.resolve(result)
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
val result = JSObject()
|
||||
result.put("success", refreshToken)
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAccessToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||
val token = serverConnectionConfig?.token ?: ""
|
||||
val ret = JSObject()
|
||||
ret.put("token", token)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun logout(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
@ -220,12 +284,11 @@ class AbsDatabase : Plugin() {
|
|||
@PluginMethod
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
apiHandler.syncLocalMediaProgressForUser {
|
||||
Log.d(tag, "Finished syncing local media progress for user")
|
||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
|
||||
if (savedSessions.isNotEmpty()) {
|
||||
|
@ -233,6 +296,7 @@ class AbsDatabase : Plugin() {
|
|||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.")
|
||||
// Remove all local sessions
|
||||
savedSessions.forEach {
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||
|
@ -241,6 +305,7 @@ class AbsDatabase : Plugin() {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.")
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -196,7 +196,11 @@ class AbsFileSystem : Plugin() {
|
|||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = file.deleteRecursively()
|
||||
success = if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
|
@ -218,7 +222,12 @@ class AbsFileSystem : Plugin() {
|
|||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
success = docfile?.delete() == true
|
||||
if (docfile?.exists() == true) {
|
||||
success = docfile.delete() == true
|
||||
} else {
|
||||
Log.d(tag, "Folder $contentUrl doesn't exist")
|
||||
success = true
|
||||
}
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.util.UUID
|
||||
|
||||
data class AbsLog(
|
||||
var id:String,
|
||||
var tag:String,
|
||||
var level:String,
|
||||
var message:String,
|
||||
var timestamp:Long
|
||||
)
|
||||
|
||||
data class AbsLogList(val value:List<AbsLog>)
|
||||
|
||||
@CapacitorPlugin(name = "AbsLogger")
|
||||
class AbsLogger : Plugin() {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
override fun load() {
|
||||
onLogEmitter = { log:AbsLog ->
|
||||
notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log)))
|
||||
}
|
||||
info("AbsLogger", "load: AbsLogger plugin initialized")
|
||||
}
|
||||
|
||||
companion object {
|
||||
var onLogEmitter:((log:AbsLog) -> Unit)? = null
|
||||
|
||||
fun log(level:String, tag:String, message:String) {
|
||||
val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis())
|
||||
DeviceManager.dbManager.saveLog(absLog)
|
||||
onLogEmitter?.let { it(absLog) }
|
||||
}
|
||||
fun info(tag:String, message:String) {
|
||||
Log.i("AbsLogger", message)
|
||||
log("info", tag, message)
|
||||
}
|
||||
fun error(tag:String, message:String) {
|
||||
Log.e("AbsLogger", message)
|
||||
log("error", tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun info(call: PluginCall) {
|
||||
val msg = call.getString("message") ?: return call.reject("No message")
|
||||
val tag = call.getString("tag") ?: ""
|
||||
info(tag, msg)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun error(call: PluginCall) {
|
||||
val msg = call.getString("message") ?: return call.reject("No message")
|
||||
val tag = call.getString("tag") ?: ""
|
||||
error(tag, msg)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAllLogs(call: PluginCall) {
|
||||
val absLogs = DeviceManager.dbManager.getAllLogs()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs))))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearLogs(call: PluginCall) {
|
||||
DeviceManager.dbManager.removeAllLogs()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
|||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
|
@ -12,6 +13,8 @@ import com.audiobookshelf.app.media.MediaProgressSyncData
|
|||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
|
@ -31,9 +34,19 @@ import java.util.concurrent.TimeUnit
|
|||
class ApiHandler(var ctx:Context) {
|
||||
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 pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
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)
|
||||
|
@ -44,10 +57,17 @@ class ApiHandler(var ctx:Context) {
|
|||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
makeRequest(request, httpClient, cb)
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
makeRequest(request, httpClient, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
|
@ -57,23 +77,38 @@ class ApiHandler(var ctx:Context) {
|
|||
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
try {
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
try {
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
val client = httpClient ?: defaultClient
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
|
@ -86,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
|||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
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) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
|
@ -118,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) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
|
@ -132,7 +437,7 @@ class ApiHandler(var ctx:Context) {
|
|||
|
||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
||||
val mapper = jacksonMapper
|
||||
getRequest("/api/libraries", null,null) {
|
||||
getRequest("/api/libraries?include=stats", null,null) {
|
||||
val libraries = mutableListOf<Library>()
|
||||
|
||||
var array = JSONArray()
|
||||
|
@ -149,6 +454,23 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getLibraryPersonalized(libraryItemId:String, cb: (List<LibraryShelfType>?) -> Unit) {
|
||||
getRequest("/api/libraries/$libraryItemId/personalized", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryStats Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val items = mutableListOf<LibraryShelfType>()
|
||||
val array = it.getJSONArray("value")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibraryShelfType>(array.get(i).toString())
|
||||
items.add(item)
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
||||
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
||||
if (it.has("error")) {
|
||||
|
@ -189,6 +511,103 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun getLibrarySeries(libraryId:String, cb: (List<LibrarySeriesItem>) -> Unit) {
|
||||
Log.d(tag, "Getting series")
|
||||
getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) {
|
||||
val items = mutableListOf<LibrarySeriesItem>()
|
||||
if (it.has("results")) {
|
||||
val array = it.getJSONArray("results")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibrarySeriesItem>(array.get(i).toString())
|
||||
items.add(item)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
Log.d(tag, "Getting items for series")
|
||||
val seriesIdBase64 = Base64.encodeToString(seriesId.toByteArray(), Base64.DEFAULT)
|
||||
getRequest("/api/libraries/$libraryId/items?minified=1&sort=media.metadata.title&filter=series.${seriesIdBase64}&limit=1000", null, null) {
|
||||
val items = mutableListOf<LibraryItem>()
|
||||
if (it.has("results")) {
|
||||
val array = it.getJSONArray("results")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
|
||||
items.add(item)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryAuthors(libraryId:String, cb: (List<LibraryAuthorItem>) -> Unit) {
|
||||
Log.d(tag, "Getting series")
|
||||
getRequest("/api/libraries/$libraryId/authors", null, null) {
|
||||
val items = mutableListOf<LibraryAuthorItem>()
|
||||
if (it.has("authors")) {
|
||||
val array = it.getJSONArray("authors")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibraryAuthorItem>(array.get(i).toString())
|
||||
items.add(item)
|
||||
}
|
||||
}else{
|
||||
Log.e(tag, "No results")
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
Log.d(tag, "Getting author items")
|
||||
val authorIdBase64 = Base64.encodeToString(authorId.toByteArray(), Base64.DEFAULT)
|
||||
getRequest("/api/libraries/$libraryId/items?limit=1000&minified=1&filter=authors.${authorIdBase64}&sort=media.metadata.title&collapseseries=1", null, null) {
|
||||
val items = mutableListOf<LibraryItem>()
|
||||
if (it.has("results")) {
|
||||
val array = it.getJSONArray("results")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
|
||||
if (item.collapsedSeries != null) {
|
||||
item.collapsedSeries?.libraryId = libraryId
|
||||
}
|
||||
items.add(item)
|
||||
}
|
||||
}else{
|
||||
Log.e(tag, "No results")
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryCollections(libraryId:String, cb: (List<LibraryCollection>) -> Unit) {
|
||||
Log.d(tag, "Getting collections")
|
||||
getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) {
|
||||
val items = mutableListOf<LibraryCollection>()
|
||||
if (it.has("results")) {
|
||||
val array = it.getJSONArray("results")
|
||||
for (i in 0 until array.length()) {
|
||||
val item = jacksonMapper.readValue<LibraryCollection>(array.get(i).toString())
|
||||
items.add(item)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSearchResults(libraryId:String, queryString:String, cb: (LibraryItemSearchResultType?) -> Unit) {
|
||||
Log.d(tag, "Doing search for library $libraryId")
|
||||
getRequest("/api/libraries/$libraryId/search?q=$queryString", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getSearchResults Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val librarySearchResults = jacksonMapper.readValue<LibraryItemSearchResultType>(it.toString())
|
||||
cb(librarySearchResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||
getRequest("/api/me/items-in-progress", null, null) {
|
||||
val items = mutableListOf<ItemInProgress>()
|
||||
|
@ -196,8 +615,7 @@ class ApiHandler(var ctx:Context) {
|
|||
val array = it.getJSONArray("libraryItems")
|
||||
for (i in 0 until array.length()) {
|
||||
val jsobj = array.get(i) as JSONObject
|
||||
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper)
|
||||
items.add(itemInProgress)
|
||||
}
|
||||
}
|
||||
|
@ -297,9 +715,9 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
|
||||
fun closePlaybackSession(playbackSessionId:String, config:ServerConnectionConfig?, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||
postRequest("/api/session/$playbackSessionId/close", null, null) {
|
||||
postRequest("/api/session/$playbackSessionId/close", null, config) {
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
|
@ -331,22 +749,27 @@ class ApiHandler(var ctx:Context) {
|
|||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
Log.e(tag, "Failed to sync local sessions")
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
response.results.forEach { localSessionSyncResult ->
|
||||
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||
|
||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}")
|
||||
} else {
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -356,37 +779,72 @@ class ApiHandler(var ctx:Context) {
|
|||
}
|
||||
|
||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}")
|
||||
|
||||
// Get all local media progress for this server
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
if (allLocalMediaProgress.isEmpty()) {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync")
|
||||
return cb()
|
||||
}
|
||||
|
||||
getCurrentUser { _user ->
|
||||
_user?.let { user->
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress")
|
||||
|
||||
getCurrentUser { user ->
|
||||
if (user == null) {
|
||||
AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})")
|
||||
} else {
|
||||
var numLocalMediaProgressUptToDate = 0
|
||||
var numLocalMediaProgressUpdated = 0
|
||||
|
||||
// Compare server user progress with local progress
|
||||
user.mediaProgress.forEach { mediaProgress ->
|
||||
// Get matching local media progress
|
||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
val updateLogs = mutableListOf<String>()
|
||||
if (mediaProgress.progress != localMediaProgress.progress) {
|
||||
updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}")
|
||||
}
|
||||
if (mediaProgress.currentTime != localMediaProgress.currentTime) {
|
||||
updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
}
|
||||
if (mediaProgress.isFinished != localMediaProgress.isFinished) {
|
||||
updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||
}
|
||||
if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) {
|
||||
updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||
}
|
||||
if (updateLogs.isNotEmpty()) {
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}")
|
||||
}
|
||||
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
|
||||
// Only report sync if progress changed
|
||||
if (updateLogs.isNotEmpty()) {
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
}
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
numLocalMediaProgressUpdated++
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}")
|
||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"")
|
||||
}
|
||||
} else {
|
||||
numLocalMediaProgressUptToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated")
|
||||
}
|
||||
cb()
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>
|
BIN
android/app/src/main/res/drawable-hdpi/md_account_outline.png
Normal file
After Width: | Height: | Size: 560 B |
BIN
android/app/src/main/res/drawable-hdpi/md_clock_outline.png
Normal file
After Width: | Height: | Size: 798 B |
BIN
android/app/src/main/res/drawable-mdpi/md_account_outline.png
Normal file
After Width: | Height: | Size: 387 B |
BIN
android/app/src/main/res/drawable-mdpi/md_clock_outline.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
android/app/src/main/res/drawable-xhdpi/md_account_outline.png
Normal file
After Width: | Height: | Size: 707 B |
BIN
android/app/src/main/res/drawable-xhdpi/md_clock_outline.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/md_account_outline.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
9
android/app/src/main/res/drawable/abs_audiobookshelf.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="27dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="27"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M26.719,15.761c-0.165,-0.138 -0.422,-0.341 -0.77,-0.581v-2.703c0,-6.891 -5.586,-12.477 -12.478,-12.477v0c-6.891,0 -12.477,5.586 -12.477,12.477v2.703c-0.348,0.241 -0.605,0.443 -0.77,0.581 -0.137,0.114 -0.223,0.285 -0.223,0.476 0,0 0,0 0,0.001v-0,3.238c0,0 0,0.001 0,0.001 0,0.191 0.086,0.361 0.222,0.475l0.001,0.001c0.385,0.321 1.269,0.993 2.645,1.683v0.315c0,0.849 0.548,1.537 1.223,1.537v0c0.675,0 1.223,-0.688 1.223,-1.537v-7.767c0,-0.849 -0.548,-1.537 -1.223,-1.537v0c-0.647,0 -1.177,0.632 -1.22,1.431l-0.003,0.002v-1.601c0,-5.856 4.747,-10.602 10.603,-10.602v0c5.856,0 10.603,4.747 10.603,10.602v1.601l-0.003,-0.002c-0.043,-0.799 -0.573,-1.431 -1.22,-1.431v0c-0.675,0 -1.223,0.688 -1.223,1.537v7.766c0,0.849 0.548,1.537 1.223,1.537v0c0.676,0 1.223,-0.688 1.223,-1.537v-0.315c1.376,-0.69 2.26,-1.362 2.645,-1.683 0.137,-0.114 0.223,-0.285 0.223,-0.476 0,-0 0,-0.001 0,-0.001v0,-3.237c0,-0 0,-0 0,-0 0,-0.191 -0.086,-0.361 -0.222,-0.475l-0.001,-0.001zM9.12,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM6.673,13.731h3.368v0.352h-3.368zM14.234,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM11.787,13.731h3.367v0.352h-3.367zM19.348,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM16.901,13.731h3.367v0.352h-3.367zM3.566,29.773h19.81c0.615,0 1.113,0.498 1.113,1.113v0c0,0.615 -0.498,1.113 -1.113,1.113h-19.81c-0.615,0 -1.113,-0.498 -1.113,-1.113v-0c0,-0.615 0.498,-1.113 1.113,-1.113z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_authors.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M16,7.333c2.577,0 4.667,2.089 4.667,4.667v0c0,2.577 -2.089,4.667 -4.667,4.667v0c-2.577,0 -4.667,-2.089 -4.667,-4.667v0c0,-2.577 2.089,-4.667 4.667,-4.667v0zM6.667,10.667c0.747,0 1.44,0.2 2.04,0.56 -0.2,1.907 0.36,3.8 1.507,5.28 -0.667,1.28 -2,2.16 -3.547,2.16 -2.209,0 -4,-1.791 -4,-4v0c0,-2.209 1.791,-4 4,-4v0zM25.333,10.667c2.209,0 4,1.791 4,4v0c0,2.209 -1.791,4 -4,4v0c-1.547,0 -2.88,-0.88 -3.547,-2.16 1.147,-1.48 1.707,-3.373 1.507,-5.28 0.6,-0.36 1.293,-0.56 2.04,-0.56zM7.333,24.333c0,-2.76 3.88,-5 8.667,-5s8.667,2.24 8.667,5v2.333h-17.333v-2.333zM0,26.667v-2c0,-1.853 2.52,-3.413 5.933,-3.867 -0.787,0.907 -1.267,2.16 -1.267,3.533v2.333h-4.667zM32,26.667h-4.667v-2.333c0,-1.373 -0.48,-2.627 -1.267,-3.533 3.413,0.453 5.933,2.013 5.933,3.867v2z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_book_1.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M28,4v26h-21c-1.657,0 -3,-1.343 -3,-3s1.343,-3 3,-3h19v-24h-20c-2.2,0 -4,1.8 -4,4v24c0,2.2 1.8,4 4,4h24v-28h-2zM7.002,26v0c-0.001,0 -0.001,0 -0.002,0 -0.552,0 -1,0.448 -1,1s0.448,1 1,1c0.001,0 0.001,-0 0.002,-0v0h18.997v-2h-18.997z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_books_1.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M12,4v20h4v-20h-4zM16,6.667l5.333,17.333 4,-1.333 -5.333,-17.333 -4,1.333zM6.667,6.667v17.333h4v-17.333h-4zM4,25.333v2.667h24v-2.667h-24z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_books_2.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="36dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="36"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M7,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM6,10h-4v-2h4v2zM17,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM16,10h-4v-2h4v2zM23.909,5.546l-5.358,2.7c-0.491,0.247 -0.691,0.852 -0.443,1.343l8.999,17.861c0.247,0.491 0.852,0.691 1.343,0.443l5.358,-2.7c0.491,-0.247 0.691,-0.852 0.443,-1.343l-8.999,-17.861c-0.247,-0.491 -0.852,-0.691 -1.343,-0.443z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_columns.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M19.2,6h-6.4v19.2h6.4v-19.2zM22.4,6v19.2h6.4v-19.2h-6.4zM9.6,6h-6.4v19.2h6.4v-19.2zM0,2.8h32v25.6h-32v-25.6z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_database.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="27dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="27"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M13.68,0c7.518,0 13.612,2.854 13.612,6.37 0,3.518 -6.096,6.37 -13.612,6.37s-13.612,-2.854 -13.612,-6.37c0,-3.516 6.096,-6.37 13.612,-6.37v0zM0.068,21.31v4.891c2.422,8.602 26.349,6.94 27.227,-0.44v-4.885c-1.195,8.102 -25.313,8.685 -27.227,0.435v0,0zM0,8.578v4.776c2.422,8.401 26.482,7.266 27.362,0.06v-4.773c-1.198,7.914 -25.448,7.995 -27.362,-0.063v0zM0,14.75v4.891c2.422,8.602 26.482,7.44 27.362,0.06v-4.885c-1.198,8.102 -25.448,8.185 -27.362,-0.065v0z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_file_picture.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M26,28h-20v-4l6,-10 8.219,10 5.781,-4v8zM26,15c0,1.657 -1.343,3 -3,3s-3,-1.343 -3,-3 1.343,-3 3,-3c1.657,0 3,1.343 3,3zM28.681,7.159c-0.694,-0.947 -1.662,-2.053 -2.724,-3.116s-2.169,-2.03 -3.116,-2.724c-1.612,-1.182 -2.393,-1.319 -2.841,-1.319h-15.5c-1.378,0 -2.5,1.121 -2.5,2.5v27c0,1.378 1.122,2.5 2.5,2.5h23c1.378,0 2.5,-1.122 2.5,-2.5v-19.5c0,-0.448 -0.137,-1.23 -1.319,-2.841zM24.543,5.457c0.959,0.959 1.712,1.825 2.268,2.543h-4.811v-4.811c0.718,0.556 1.584,1.309 2.543,2.268zM28,29.5c0,0.271 -0.229,0.5 -0.5,0.5h-23c-0.271,0 -0.5,-0.229 -0.5,-0.5v-27c0,-0.271 0.229,-0.5 0.5,-0.5 0,0 15.499,-0 15.5,0v7c0,0.552 0.448,1 1,1h7v19.5z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_headphones.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M9,18h-2v14h2c0.55,0 1,-0.45 1,-1v-12c0,-0.55 -0.45,-1 -1,-1zM23,18c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2v-14h-2zM32,16c0,-8.837 -7.163,-16 -16,-16s-16,7.163 -16,16c0,1.919 0.338,3.759 0.958,5.464 -0.609,1.038 -0.958,2.246 -0.958,3.536 0,3.526 2.608,6.443 6,6.929v-13.857c-0.997,0.143 -1.927,0.495 -2.742,1.012 -0.168,-0.835 -0.258,-1.699 -0.258,-2.584 0,-7.18 5.82,-13 13,-13s13,5.82 13,13c0,0.885 -0.088,1.749 -0.257,2.584 -0.816,-0.517 -1.745,-0.87 -2.743,-1.013v13.858c3.392,-0.485 6,-3.402 6,-6.929 0,-1.29 -0.349,-2.498 -0.958,-3.536 0.62,-1.705 0.958,-3.545 0.958,-5.465z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_heart.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M23.6,2c-3.363,0 -6.258,2.736 -7.599,5.594 -1.342,-2.858 -4.237,-5.594 -7.601,-5.594 -4.637,0 -8.4,3.764 -8.4,8.401 0,9.433 9.516,11.906 16.001,21.232 6.13,-9.268 15.999,-12.1 15.999,-21.232 0,-4.637 -3.763,-8.401 -8.4,-8.401z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_microphone_1.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M6,6v10c0,3.313 2.688,6 6,6s6,-2.688 6,-6h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5c0,-3.313 -2.688,-6 -6,-6s-6,2.688 -6,6zM20,15v1c0,4.419 -3.581,8 -8,8s-8,-3.581 -8,-8v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v2.5c0,5.569 4.138,10.169 9.5,10.9v2.1h-3c-0.831,0 -1.5,0.669 -1.5,1.5s0.669,1.5 1.5,1.5h9c0.831,0 1.5,-0.669 1.5,-1.5s-0.669,-1.5 -1.5,-1.5h-3v-2.1c5.363,-0.731 9.5,-5.331 9.5,-10.9v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v1.5z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_microphone_2.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="22dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="22"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M22.292,14.872c0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,2.977 -0.714,5.435 -2.159,7.076 -1.31,1.484 -3.289,2.341 -5.964,2.341s-4.654,-0.854 -5.964,-2.339c-1.448,-1.641 -2.159,-4.099 -2.159,-7.078 0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,3.711 0.961,6.857 2.914,9.073 1.438,1.63 3.375,2.734 5.815,3.164v2.479h-3.703c-0.661,0 -1.203,0.542 -1.203,1.203v1.206h14.646v-1.206c0,-0.661 -0.542,-1.203 -1.203,-1.203h-3.711v-2.479c2.44,-0.432 4.375,-1.534 5.815,-3.167 1.953,-2.214 2.917,-5.359 2.917,-9.07v0,0zM11.146,0c3.083,0 5.604,2.523 5.604,5.604v0.146h-3.013v3.57h3.016v1.818h-3.016v3.57h3.016v1.284c0,3.083 -2.523,5.604 -5.604,5.604 -3.083,0 -5.604,-2.523 -5.604,-5.604v-1.284h3.016v-3.57h-3.018v-1.818h3.016v-3.57h-3.016v-0.146c0,-3.081 2.521,-5.604 5.604,-5.604v0z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_microphone_3.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M15,22c2.761,0 5,-2.239 5,-5v-12c0,-2.761 -2.239,-5 -5,-5s-5,2.239 -5,5v12c0,2.761 2.239,5 5,5zM22,14v3c0,3.866 -3.134,7 -7,7s-7,-3.134 -7,-7v-3h-2v3c0,4.632 3.5,8.447 8,8.944v4.056h-4v2h10v-2h-4v-4.056c4.5,-0.497 8,-4.312 8,-8.944v-3h-2z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_music.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M30,0h2v23c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-11.501l-16,3.556v15.444c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-19.501l18,-4z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_podcast.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="33dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="33"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M18.289,18.549v13.45h-3.973v-13.45c-1.305,-0.706 -2.191,-2.087 -2.191,-3.676 0,-2.307 1.871,-4.178 4.178,-4.178s4.178,1.871 4.178,4.178c0,1.589 -0.887,2.97 -2.192,3.676v0zM10.434,2.963c0.774,-0.382 1.091,-1.319 0.709,-2.091s-1.318,-1.091 -2.091,-0.71c-3.158,1.558 -5.613,4.134 -7.165,7.169 -1.146,2.241 -1.8,4.734 -1.879,7.251 -0.079,2.538 0.421,5.101 1.585,7.46 1.433,2.906 3.863,5.491 7.44,7.322 0.767,0.392 1.706,0.088 2.098,-0.679s0.088,-1.706 -0.679,-2.097c-2.929,-1.498 -4.905,-3.589 -6.059,-5.926 -0.93,-1.887 -1.33,-3.942 -1.267,-5.981 0.064,-2.058 0.599,-4.098 1.536,-5.93 1.256,-2.455 3.234,-4.536 5.771,-5.786v0zM23.554,0.161c-0.773,-0.382 -1.71,-0.064 -2.091,0.71s-0.064,1.71 0.71,2.091c2.537,1.251 4.515,3.332 5.771,5.787 0.937,1.832 1.472,3.871 1.536,5.93 0.064,2.038 -0.336,4.094 -1.267,5.981 -1.153,2.337 -3.13,4.428 -6.059,5.926 -0.767,0.392 -1.07,1.331 -0.679,2.097s1.331,1.071 2.098,0.679c3.577,-1.83 6.007,-4.415 7.44,-7.322 1.164,-2.359 1.664,-4.922 1.585,-7.46 -0.079,-2.516 -0.732,-5.01 -1.878,-7.251 -1.552,-3.034 -4.008,-5.611 -7.165,-7.169v0zM21.968,7.033c-0.582,-0.42 -1.395,-0.287 -1.814,0.295s-0.287,1.395 0.295,1.814c0.235,0.169 0.465,0.359 0.691,0.566 1.319,1.216 2.101,2.838 2.266,4.553 0.166,1.731 -0.295,3.566 -1.464,5.188 -0.198,0.276 -0.427,0.555 -0.686,0.836 -0.487,0.529 -0.453,1.353 0.076,1.839s1.353,0.452 1.84,-0.076c0.313,-0.339 0.607,-0.701 0.88,-1.081 1.555,-2.16 2.167,-4.617 1.943,-6.951 -0.225,-2.349 -1.293,-4.566 -3.091,-6.224 -0.283,-0.261 -0.595,-0.514 -0.935,-0.76v0zM12.156,9.143c0.582,-0.42 0.715,-1.232 0.295,-1.814s-1.232,-0.715 -1.814,-0.296c-0.341,0.246 -0.652,0.499 -0.935,0.76 -1.799,1.658 -2.866,3.875 -3.091,6.224 -0.224,2.334 0.387,4.792 1.943,6.952 0.274,0.379 0.567,0.741 0.88,1.08 0.487,0.529 1.311,0.563 1.839,0.076s0.563,-1.311 0.076,-1.839c-0.259,-0.281 -0.487,-0.56 -0.686,-0.836 -1.169,-1.623 -1.63,-3.457 -1.464,-5.188 0.164,-1.715 0.947,-3.337 2.266,-4.553 0.226,-0.207 0.456,-0.397 0.691,-0.566v0z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_radio.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M30.925,2.938c0.794,-0.231 1.25,-1.069 1.019,-1.863s-1.069,-1.25 -1.869,-1.012l-26.844,7.869c-0.587,0.169 -1.119,0.456 -1.569,0.825 -1.006,0.725 -1.663,1.906 -1.663,3.244v16c0,2.206 1.794,4 4,4h24c2.206,0 4,-1.794 4,-4v-16c0,-2.206 -1.794,-4 -4,-4h-14.344l17.269,-5.063zM23,25c-2.762,0 -5,-2.238 -5,-5s2.238,-5 5,-5 5,2.238 5,5 -2.238,5 -5,5zM5,16c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1zM4,20c0,-0.55 0.45,-1 1,-1h8c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-8c-0.55,0 -1,-0.45 -1,-1zM5,24c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_rocket.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M22,2l-10,10h-6l-6,8c0,0 6.357,-1.77 10.065,-0.94l-10.065,12.94 13.184,-10.255c1.839,4.208 -1.184,10.255 -1.184,10.255l8,-6v-6l10,-10 2,-10 -10,2z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_rss.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M4.259,23.467c-2.35,0 -4.259,1.917 -4.259,4.252 0,2.349 1.909,4.244 4.259,4.244 2.358,0 4.265,-1.895 4.265,-4.244 -0,-2.336 -1.907,-4.252 -4.265,-4.252zM0.005,10.873v6.133c3.993,0 7.749,1.562 10.577,4.391 2.825,2.822 4.384,6.595 4.384,10.603h6.16c-0,-11.651 -9.478,-21.127 -21.121,-21.127zM0.012,0v6.136c14.243,0 25.836,11.604 25.836,25.864h6.152c0,-17.64 -14.352,-32 -31.988,-32z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
9
android/app/src/main/res/drawable/abs_star.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="32"
|
||||
android:viewportHeight="32">
|
||||
<path
|
||||
android:pathData="M32,12.408l-11.056,-1.607 -4.944,-10.018 -4.944,10.018 -11.056,1.607 8,7.798 -1.889,11.011 9.889,-5.199 9.889,5.199 -1.889,-11.011 8,-7.798z"
|
||||
android:fillColor="#fff"/>
|
||||
</vector>
|
1
android/app/src/main/res/drawable/md_account_outline.xml
Normal file
|
@ -0,0 +1 @@
|
|||
<!-- drawable/account_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" /></vector>
|
|
@ -0,0 +1 @@
|
|||
<!-- drawable/book_multiple_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M19 2A2 2 0 0 1 21 4V16A2 2 0 0 1 19 18H9A2 2 0 0 1 7 16V4A2 2 0 0 1 9 2H19M19 4H16V10L13.5 7.75L11 10V4H9V16H19M3 20A2 2 0 0 0 5 22H17V20H5V6H3Z" /></vector>
|
|
@ -0,0 +1 @@
|
|||
<!-- drawable/book_open_blank_variant_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12 21.5C10.65 20.65 8.2 20 6.5 20C4.85 20 3.15 20.3 1.75 21.05C1.65 21.1 1.6 21.1 1.5 21.1C1.25 21.1 1 20.85 1 20.6V6C1.6 5.55 2.25 5.25 3 5C4.11 4.65 5.33 4.5 6.5 4.5C8.45 4.5 10.55 4.9 12 6C13.45 4.9 15.55 4.5 17.5 4.5C18.67 4.5 19.89 4.65 21 5C21.75 5.25 22.4 5.55 23 6V20.6C23 20.85 22.75 21.1 22.5 21.1C22.4 21.1 22.35 21.1 22.25 21.05C20.85 20.3 19.15 20 17.5 20C15.8 20 13.35 20.65 12 21.5M11 7.5C9.64 6.9 7.84 6.5 6.5 6.5C5.3 6.5 4.1 6.65 3 7V18.5C4.1 18.15 5.3 18 6.5 18C7.84 18 9.64 18.4 11 19V7.5M13 19C14.36 18.4 16.16 18 17.5 18C18.7 18 19.9 18.15 21 18.5V7C19.9 6.65 18.7 6.5 17.5 6.5C16.16 6.5 14.36 6.9 13 7.5V19Z" /></vector>
|
1
android/app/src/main/res/drawable/md_clock_outline.xml
Normal file
|
@ -0,0 +1 @@
|
|||
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>
|
1
android/app/src/main/res/drawable/md_telescope.xml
Normal file
|
@ -0,0 +1 @@
|
|||
<!-- drawable/telescope.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M21.9,8.9L20.2,9.9L16.2,3L17.9,2L21.9,8.9M9.8,7.9L12.8,13.1L18.9,9.6L15.9,4.4L9.8,7.9M11.4,12.7L9.4,9.2L5.1,11.7L7.1,15.2L11.4,12.7M2.1,14.6L3.1,16.3L5.7,14.8L4.7,13.1L2.1,14.6M12.1,14L11.8,13.6L7.5,16.1L7.8,16.5C8,16.8 8.3,17.1 8.6,17.3L7,22H9L10.4,17.7H10.5L12,22H14L12.1,16.4C12.6,15.7 12.6,14.8 12.1,14Z" /></vector>
|
|
@ -7,6 +7,7 @@
|
|||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
BIN
android/app/src/main/res/raw/bell.mp3
Normal file
|
@ -10,7 +10,7 @@
|
|||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="windowActionBar">false</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:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
|
|
@ -3,5 +3,5 @@
|
|||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
<color name="background_dark">#262626</color>
|
||||
</resources>
|
||||
<color name="background_dark">#232323</color>
|
||||
</resources>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</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:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.20'
|
||||
ext.kotlin_version = '2.0.0'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
classpath 'com.android.tools.build:gradle:8.8.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
@ -26,7 +26,7 @@ allprojects {
|
|||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
tasks.register('clean', Delete) {
|
||||
delete rootProject.layout.buildDirectory
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,14 @@
|
|||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
include ':webnativellc-capacitor-filesharer'
|
||||
project(':webnativellc-capacitor-filesharer').projectDir = new File('../node_modules/@webnativellc/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-community-keep-awake'
|
||||
project(':capacitor-community-keep-awake').projectDir = new File('../node_modules/@capacitor-community/keep-awake/android')
|
||||
|
||||
include ':capacitor-community-volume-buttons'
|
||||
project(':capacitor-community-volume-buttons').projectDir = new File('../node_modules/@capacitor-community/volume-buttons/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
|
|
@ -31,4 +31,6 @@ android.useAndroidX=true
|
|||
|
||||
kapt.use.worker.api=false
|
||||
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.buildfeatures.buildconfig=true
|
||||
|
||||
org.gradle.warning.mode=all
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0"
|
||||
}
|
||||
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
|
|
@ -1,39 +1,24 @@
|
|||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.7.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.10.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
androidxFragmentVersion = '1.5.6'
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.12.0'
|
||||
androidx_media_version = '1.6.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
constraint_layout_version = '2.0.1'
|
||||
espresso_version = '3.3.0'
|
||||
androidx_core_ktx_version = '1.16.0'
|
||||
androidx_media_version = '1.7.0'
|
||||
exoplayer_version = '2.18.7'
|
||||
fragment_version = '1.2.5'
|
||||
glide_version = '4.11.0'
|
||||
gms_strict_version_matcher_version = '1.0.3'
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.8.10'
|
||||
kotlin_coroutines_version = '1.6.4'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
recycler_view_version = '1.1.0'
|
||||
robolectric_version = '4.2'
|
||||
glide_version = '4.16.0'
|
||||
junit_version = '4.13.2'
|
||||
kotlin_version = '2.1.0'
|
||||
kotlin_coroutines_version = '1.10.1'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.6.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
@import "./tailwind.css";
|
||||
@import "./fonts.css";
|
||||
@import './tailwind.css';
|
||||
@import './fonts.css';
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.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-progressbar {
|
||||
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
|
@ -164,4 +168,4 @@ Bookshelf Label
|
|||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,4 +52,16 @@
|
|||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.default-style.less-spacing p {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ul {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ol {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
|
|
@ -1,19 +1,12 @@
|
|||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
|
||||
src: url(/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
|
@ -24,31 +17,14 @@
|
|||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
.material-symbols.fill {
|
||||
font-variation-settings:
|
||||
'FILL' 1
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
|
@ -317,4 +293,4 @@
|
|||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
||||
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'] {
|
||||
color: black;
|
||||
--color-bg: 255 255 255;
|
||||
|
|
|
@ -2,10 +2,16 @@
|
|||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
},
|
||||
"StatusBar": {
|
||||
"backgroundColor": "#232323",
|
||||
"style": "DARK"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"androidScheme": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary && networkConnected">
|
||||
<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">
|
||||
<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>
|
||||
|
@ -21,16 +21,18 @@
|
|||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- 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 pt-0.5" @click="castClick">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
||||
<span class="material-symbols text-2xl leading-none">
|
||||
{{ isCasting ? 'cast_connected' : 'cast' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||
<span class="material-icons">search</span>
|
||||
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search">
|
||||
<span class="material-symbols text-2xl leading-none">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-1.5">
|
||||
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -54,9 +56,6 @@ export default {
|
|||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
|
@ -101,14 +100,14 @@ export default {
|
|||
this.isCastAvailable = data && data.value
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
||||
this.isCastAvailable = data && data.value
|
||||
})
|
||||
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||
this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
|
||||
this.onCastAvailableUpdateListener?.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -160,4 +159,4 @@ export default {
|
|||
transform: translate(10px, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|