Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8bc5932889 | ||
|
|
3cd597d71e | ||
|
|
48d5ad462e | ||
|
|
40cab87640 | ||
|
|
f405947ad8 | ||
|
|
fd550c15a5 | ||
|
|
d18537cf04 | ||
|
|
f01f0229a8 | ||
|
|
b369eb2d40 | ||
|
|
f08c65c5bf | ||
|
|
1043f4ecd7 | ||
|
|
4f17a32373 | ||
|
|
2203aae6e6 | ||
|
|
7b60c7b6c5 | ||
|
|
be98e06efc | ||
|
|
45239bca34 | ||
|
|
84509ec1a8 | ||
|
|
2493ce7ed7 | ||
|
|
2b1e53b371 | ||
|
|
d643a1769f | ||
|
|
a63b7a188c | ||
|
|
f9c0162b8a | ||
|
|
cda3cb3512 | ||
|
|
766f6938a7 | ||
|
|
8e1ccb517b | ||
|
|
540aab6f83 | ||
|
|
d1969d7897 | ||
|
|
b69e8fd649 | ||
|
|
2e95cf908d | ||
|
|
b589bae2b0 | ||
|
|
e7e4891d82 | ||
|
|
79df676a13 | ||
|
|
03a46decba | ||
|
|
d80482f443 | ||
|
|
735cce7614 | ||
|
|
8ea35a0b36 | ||
|
|
ad90bd3d05 | ||
|
|
fa3856e845 | ||
|
|
752d4ddc8c | ||
|
|
fd2d7a05d1 | ||
|
|
ccf4675867 | ||
|
|
bc043c73ca | ||
|
|
291ab247bc | ||
|
|
d901f470a1 | ||
|
|
0ebb01e39d | ||
|
|
20ac99bf8d | ||
|
|
90e86e1575 | ||
|
|
28652031e7 | ||
|
|
cd8be40cc8 | ||
|
|
52fc937f96 | ||
|
|
68e4722387 | ||
|
|
5f5b38b2f9 | ||
|
|
65f8d3ce81 | ||
|
|
ac571fd63d | ||
|
|
ea2d6e9f31 | ||
|
|
80b999d648 | ||
|
|
54720039e4 | ||
|
|
8c6717c201 | ||
|
|
6610754e25 | ||
|
|
d38622d8a2 | ||
|
|
cd2750a489 | ||
|
|
30140629ae | ||
|
|
0ce29337ef | ||
|
|
6715f29ebb | ||
|
|
b4a8dc9e26 | ||
|
|
c349a0bf00 | ||
|
|
0dc6c142be | ||
|
|
e46c1d9120 | ||
|
|
27ad554ea1 | ||
|
|
5660772b46 | ||
|
|
dab80642ab | ||
|
|
da46191208 | ||
|
|
6484c4de79 | ||
|
|
6716b6042a | ||
|
|
0686859fa7 | ||
|
|
4180862c3a | ||
|
|
7aa20d3211 | ||
|
|
b694b55710 | ||
|
|
024b613063 | ||
|
|
8768d11a20 | ||
|
|
5898303ba4 | ||
|
|
e79d09941c | ||
|
|
c52a60fe77 | ||
|
|
e64f9aa427 | ||
|
|
ee343f78ef | ||
|
|
1f20d5821f | ||
|
|
b0c512e946 | ||
|
|
0d13b7abd1 | ||
|
|
43fd8285c9 | ||
|
|
2f7e8d618a | ||
|
|
a0b910deeb | ||
|
|
e9dc45613c | ||
|
|
b5e90ec3d2 | ||
|
|
3d9f42c1e1 | ||
|
|
e62f430f37 | ||
|
|
9c53564c46 | ||
|
|
f7add6f6cd | ||
|
|
7786e6a827 | ||
|
|
b213e2aabf | ||
|
|
ebca0fafca | ||
|
|
ac8a2ca985 | ||
|
|
6380d7cc02 | ||
|
|
9056ba6b8d | ||
|
|
23801363c4 | ||
|
|
1cdcd7327f | ||
|
|
50ec50003e | ||
|
|
5ff4db1a3f | ||
|
|
394b44c343 | ||
|
|
eb2aa6f773 | ||
|
|
81130e04e5 | ||
|
|
c0ec0758f4 | ||
|
|
e21f7c545e | ||
|
|
c16dd2fcf6 | ||
|
|
90974e6d73 | ||
|
|
8e4f75d85b | ||
|
|
792ee2adb8 | ||
|
|
2c57489c07 | ||
|
|
e3321770fd | ||
|
|
848673f028 | ||
|
|
e6d3003ecf | ||
|
|
90551b7ce7 | ||
|
|
aabac5da13 | ||
|
|
4fad77f9c1 | ||
|
|
0fb6d746eb | ||
|
|
7fc712d19b | ||
|
|
d6018cfe10 | ||
|
|
5b8f231a98 | ||
|
|
cb503f84e1 | ||
|
|
a34f9b80ac | ||
|
|
3e6696b516 | ||
|
|
9cd8bd439a | ||
|
|
ca5b99e23d | ||
|
|
c44fd74f5b | ||
|
|
f82c4834c3 | ||
|
|
dda95bd69c | ||
|
|
a623cb57a7 | ||
|
|
e798d930fb | ||
|
|
f91b0c9e2f | ||
|
|
b6824cf242 | ||
|
|
41477fdac1 | ||
|
|
d4e8afc2f1 | ||
|
|
d42cbfe3e4 | ||
|
|
c7c3ef52e6 | ||
|
|
92344325b6 | ||
|
|
a8172fb3f9 | ||
|
|
a73ec783d5 | ||
|
|
5c9fc4c9e4 | ||
|
|
494ff38ef3 | ||
|
|
b7e6cbf1ab | ||
|
|
b33623b74f | ||
|
|
4f939ce6fc | ||
|
|
4f45f360df | ||
|
|
4ad54f34fe | ||
|
|
574dda5286 | ||
|
|
d788623509 | ||
|
|
f047f6a23b | ||
|
|
48b30f6b03 | ||
|
|
67d32164d9 | ||
|
|
6672a21049 | ||
|
|
b833019a14 | ||
|
|
00eeeb2a5a | ||
|
|
06a0a68143 | ||
|
|
1a7ae468bd | ||
|
|
e8f74d7358 | ||
|
|
984f01da23 | ||
|
|
d6e75410a5 | ||
|
|
7627e1b58c | ||
|
|
5fb31b18ae | ||
|
|
20903e8217 | ||
|
|
8fe348d4eb | ||
|
|
24cda8a179 | ||
|
|
fc954a968b | ||
|
|
aae53a54cd | ||
|
|
cfedcffc98 | ||
|
|
0e74c74a6c | ||
|
|
fb86c45e78 | ||
|
|
124300b215 | ||
|
|
6746c0627b | ||
|
|
0f2cd3b132 | ||
|
|
e94d6fdc1e | ||
|
|
e301b3071c | ||
|
|
3d3dad5e4a | ||
|
|
f8a9033738 | ||
|
|
edb25f5bcd | ||
|
|
bcb239efd1 | ||
|
|
d208211c6f | ||
|
|
0b20b72241 | ||
|
|
5c29459af2 | ||
|
|
e54fd38784 | ||
|
|
94774f13df | ||
|
|
43ec35fe67 | ||
|
|
b2645dc444 | ||
|
|
c79624a82c | ||
|
|
8201f3c9a6 | ||
|
|
e0ac6a1c38 | ||
|
|
400da5566d | ||
|
|
9b67037774 | ||
|
|
48596542c7 | ||
|
|
9715d65d88 | ||
|
|
2e14b31a69 | ||
|
|
1c8ce6c3db | ||
|
|
dc74a6213d | ||
|
|
e9a66bb7a7 | ||
|
|
3c6d4b5f19 | ||
|
|
f04eebe0d8 | ||
|
|
63b5227ccc | ||
|
|
934d9b1df4 | ||
|
|
8994cb3a6e | ||
|
|
4a32de482b | ||
|
|
95f47793d2 | ||
|
|
f27048618d | ||
|
|
c90ad419e8 | ||
|
|
2cdc3ecb9d | ||
|
|
9f89fcd968 | ||
|
|
7c2d51f811 | ||
|
|
41fe7a29cd | ||
|
|
54f3eec303 | ||
|
|
cd91013f69 | ||
|
|
0af16ef05f | ||
|
|
5ba830d56e | ||
|
|
81919e963a | ||
|
|
8a1d4bb958 | ||
|
|
eabac0e709 | ||
|
|
d90783d7c8 | ||
|
|
faf8211e5c | ||
|
|
65d90947c5 | ||
|
|
752e918d26 | ||
|
|
ad08cc69f4 | ||
|
|
da3882fd5e | ||
|
|
c04c7a55d9 | ||
|
|
3318846b43 | ||
|
|
e0860feeb9 | ||
|
|
c0253a69b9 | ||
|
|
9242c7e6e0 | ||
|
|
0bfc579ae4 | ||
|
|
5fad06756b | ||
|
|
5be6afa6c1 | ||
|
|
5ba3b8be7a | ||
|
|
0eb62f541d | ||
|
|
6baff881c7 | ||
|
|
8c23c1e795 | ||
|
|
9706c66a27 | ||
|
|
f4f7252537 | ||
|
|
430d200151 | ||
|
|
7be82d03f3 | ||
|
|
15aaef742d | ||
|
|
e443ba09da | ||
|
|
8235d1f1f7 | ||
|
|
9a39dcceee | ||
|
|
a707b7fc86 | ||
|
|
b6fea85ef4 | ||
|
|
7e3b3f55ae | ||
|
|
d2d3bbe8a1 | ||
|
|
41a65c0f9d | ||
|
|
165ddd58a6 | ||
|
|
09dc04c0de | ||
|
|
57d5dbc0d0 | ||
|
|
11af75e127 | ||
|
|
f8d82a3020 | ||
|
|
81d7b1cfd7 | ||
|
|
e15c1bcb32 | ||
|
|
826d414d9f | ||
|
|
0da6fca727 | ||
|
|
2327bc1705 | ||
|
|
3bb76cecac | ||
|
|
e3f82bb237 | ||
|
|
fcd6270f2d | ||
|
|
d5411703ca | ||
|
|
8fc2058db8 | ||
|
|
d03949f5d1 | ||
|
|
ff01786063 | ||
|
|
bb117ab4da | ||
|
|
f5ec247b47 | ||
|
|
6e6dd00523 | ||
|
|
c9fb98f033 | ||
|
|
75ecff5e6d | ||
|
|
91a9f918fb | ||
|
|
03305f90ca | ||
|
|
9428bc07c5 | ||
|
|
f38f9c3502 | ||
|
|
64a796aece | ||
|
|
63039fe8c9 | ||
|
|
3f2b723d0f | ||
|
|
ffd7fb1cf9 | ||
|
|
b049b206cf | ||
|
|
35f56dc8a9 | ||
|
|
a71cd326cc | ||
|
|
cc841d1c7c | ||
|
|
16ecdcf96e | ||
|
|
96ec56d1ad | ||
|
|
9b15bafc9f | ||
|
|
aa02f2e465 | ||
|
|
15464617ef | ||
|
|
60ecceb59d | ||
|
|
3fe943e989 | ||
|
|
5e7d039454 | ||
|
|
f928df146a | ||
|
|
6e57cb49c4 | ||
|
|
2f8494b013 | ||
|
|
6f1b93bd9f | ||
|
|
19b928d7f6 | ||
|
|
b849be6f5e | ||
|
|
86096df1a4 | ||
|
|
98a465c7b4 | ||
|
|
3315f61ae4 | ||
|
|
f977445d7a | ||
|
|
095d5e1cec | ||
|
|
69fbfa4b33 | ||
|
|
1c89d33726 | ||
|
|
acee907932 | ||
|
|
08ad216fe2 | ||
|
|
3cc7a0937c | ||
|
|
322cad5548 | ||
|
|
73d70dd480 | ||
|
|
56bc569d4a | ||
|
|
981ae58f6a | ||
|
|
b2bfa4c002 | ||
|
|
8b6003c5e3 | ||
|
|
017b6237ca | ||
|
|
d5496c2e9a | ||
|
|
5ef8961c33 | ||
|
|
e8bea48577 | ||
|
|
6cae78f5c2 | ||
|
|
d9d31a0620 | ||
|
|
3682c5fca2 | ||
|
|
14b04a14b6 | ||
|
|
f218d90bfb | ||
|
|
020f936ad4 | ||
|
|
224e5efe48 | ||
|
|
439ab2c8c0 | ||
|
|
26d0209763 | ||
|
|
29086fc783 | ||
|
|
bb656a265f | ||
|
|
b11ad9dc5b | ||
|
|
2b1667e532 | ||
|
|
228a198598 | ||
|
|
2843308fb8 | ||
|
|
793f0c05f7 | ||
|
|
cf7271dc57 | ||
|
|
54664b0c32 | ||
|
|
ecf910dc55 | ||
|
|
460daec72e | ||
|
|
4dd67adcab | ||
|
|
023c83ee78 | ||
|
|
2b8ed16616 | ||
|
|
6ae4d72b68 | ||
|
|
c6bd93ef5c | ||
|
|
e1c02ce74c | ||
|
|
efc6d68403 | ||
|
|
7412d0899f | ||
|
|
42eb0a8384 | ||
|
|
27e83e8ed9 | ||
|
|
dd66042766 | ||
|
|
dc790d5450 | ||
|
|
01ac844024 | ||
|
|
c8064ac7bc | ||
|
|
f5da44dc6f | ||
|
|
cbdff29684 | ||
|
|
19d6c84113 | ||
|
|
d9f4ed3d54 | ||
|
|
fdb26b7700 | ||
|
|
94cb7a65a2 | ||
|
|
9298065934 | ||
|
|
1cf01a5d12 | ||
|
|
e3aa96cbd5 | ||
|
|
c7678da664 | ||
|
|
52701048ad | ||
|
|
776c02828f | ||
|
|
69308413f6 | ||
|
|
84e376a7cf | ||
|
|
1822b21af8 | ||
|
|
8b222435e6 | ||
|
|
fdd251ec2c | ||
|
|
78170d4e10 | ||
|
|
837981b8b6 | ||
|
|
be23c6703f | ||
|
|
0c72889917 | ||
|
|
02746bc35f | ||
|
|
85a5c33bd8 | ||
|
|
5067c0f6d7 | ||
|
|
89938ab120 | ||
|
|
9a32396daa | ||
|
|
ddb1796891 | ||
|
|
76253b7b9d | ||
|
|
74fef1d2f4 | ||
|
|
7306ffa709 | ||
|
|
6f289293cc | ||
|
|
783f924bb9 | ||
|
|
044a35aacf | ||
|
|
78448894d2 | ||
|
|
aca348e783 | ||
|
|
e6c1874cbb | ||
|
|
1b06dbd78c | ||
|
|
a26f37aa49 | ||
|
|
8af3c09458 | ||
|
|
fed6579e08 | ||
|
|
6621f8b2ee | ||
|
|
737b6d7c26 | ||
|
|
59c71b71fb | ||
|
|
f89148b92e | ||
|
|
88fbebe5c4 | ||
|
|
e8abb18350 | ||
|
|
cc9dead1eb | ||
|
|
f9dd8188ee | ||
|
|
579785d2c6 | ||
|
|
257a1ceb51 | ||
|
|
5c60cd136c | ||
|
|
2bfe548c52 | ||
|
|
164fd92a18 | ||
|
|
0036a19659 | ||
|
|
ef4d7ba1bf | ||
|
|
8dbffa7993 | ||
|
|
17ff2ed5f4 | ||
|
|
6fe470cfc1 | ||
|
|
301e9b213f | ||
|
|
2b533524fc | ||
|
|
632b1f526b | ||
|
|
81b11bac93 | ||
|
|
a88e404ccf | ||
|
|
a7cbdbfbbb | ||
|
|
ad5146e2ee | ||
|
|
36be91962c | ||
|
|
ff5a1bb09f | ||
|
|
5060554640 | ||
|
|
93cb319e97 | ||
|
|
a42dfa5649 | ||
|
|
6a938b8da1 | ||
|
|
4f994072ad | ||
|
|
7c0ef179ec | ||
|
|
5fdd0c6672 | ||
|
|
e9f523d7cb | ||
|
|
c1b13dcd24 | ||
|
|
0daa043f14 | ||
|
|
1a6b716046 | ||
|
|
945baa24f0 | ||
|
|
30d4e709f0 | ||
|
|
8d8782a5a9 | ||
|
|
087e8553c4 | ||
|
|
2984398051 | ||
|
|
e9251db647 | ||
|
|
6c88337180 | ||
|
|
74b488ad0f | ||
|
|
fc7af6d1fc | ||
|
|
737d8f19b3 | ||
|
|
d613d8954d | ||
|
|
04e468b43d | ||
|
|
dc8178769b | ||
|
|
8b631f7860 | ||
|
|
e521ddfab6 | ||
|
|
6b164bdb27 | ||
|
|
2eb399b8a7 | ||
|
|
46f558d6e0 | ||
|
|
00bed39733 | ||
|
|
2048795c92 | ||
|
|
8c9bd53934 | ||
|
|
01178d00bf | ||
|
|
7541e4b660 | ||
|
|
2366c938b5 | ||
|
|
59480ad114 | ||
|
|
ce7779607f | ||
|
|
04bc74babe | ||
|
|
ac2b674ba5 | ||
|
|
e53901e7a4 | ||
|
|
3ede41f496 | ||
|
|
0adeaaa04d | ||
|
|
57d1fbfa83 | ||
|
|
27bbe40874 | ||
|
|
60da6093f3 | ||
|
|
259c9cef20 | ||
|
|
6d61b2acaf | ||
|
|
72f687b079 | ||
|
|
0ce3c8bcbd | ||
|
|
f9621d2f60 | ||
|
|
e634132a39 | ||
|
|
59b428100b | ||
|
|
36e0c91e27 | ||
|
|
03225957aa | ||
|
|
77f70e48de | ||
|
|
cbdc3513c7 | ||
|
|
394479bea0 | ||
|
|
b9592e46a7 | ||
|
|
5810a83c66 | ||
|
|
389c7d23ca | ||
|
|
defc7d216a | ||
|
|
852702112a | ||
|
|
dd91bc6667 | ||
|
|
cdbbd56a58 | ||
|
|
d6a1eba657 | ||
|
|
da899bfddd | ||
|
|
b7b746fb2a | ||
|
|
c8053355df | ||
|
|
1ddb6eca00 | ||
|
|
f42c624cba | ||
|
|
f8fbeef9b7 | ||
|
|
46b82a81ce | ||
|
|
48342e5bd3 | ||
|
|
147f89f870 | ||
|
|
8b39ba9c92 | ||
|
|
13d32dc1f7 | ||
|
|
0d1312e497 | ||
|
|
ff4f8324e7 | ||
|
|
b4bf10d409 | ||
|
|
ae37861f7d | ||
|
|
1da03cdd65 | ||
|
|
a7443d0992 | ||
|
|
5a953464ab | ||
|
|
953bde5a42 | ||
|
|
481d0d0285 | ||
|
|
7b3a005ad6 | ||
|
|
4f9d341f69 | ||
|
|
9e7d1d1617 | ||
|
|
4f03c2a35d | ||
|
|
db3c5396e9 | ||
|
|
455af239a1 | ||
|
|
d8bc26f5f8 | ||
|
|
543ac209e4 | ||
|
|
c8b5cefeb5 | ||
|
|
f1411011e6 | ||
|
|
f3964dda17 | ||
|
|
d207e88e18 | ||
|
|
2643e98fc0 | ||
|
|
410648e316 | ||
|
|
373221703d | ||
|
|
fbcb8620f9 | ||
|
|
a8ac4970cc | ||
|
|
195e33ae32 | ||
|
|
9acf695802 | ||
|
|
e6aaccfc74 | ||
|
|
87c74fe78b | ||
|
|
7dc319c1bd | ||
|
|
c093548b71 | ||
|
|
b067a2d3d5 | ||
|
|
a9ae7bfdb8 | ||
|
|
9dcf22614c | ||
|
|
c7564d439e | ||
|
|
157dc01673 | ||
|
|
cce6e1d0ab | ||
|
|
b935cb5cf1 | ||
|
|
9ab37ac525 | ||
|
|
7031c895b5 | ||
|
|
7c739bf3bf | ||
|
|
facf56930e | ||
|
|
2c3dff3544 | ||
|
|
b1bf68b8cd | ||
|
|
78aade7694 | ||
|
|
83a4474375 | ||
|
|
1b47c38efa | ||
|
|
922d9f22ce | ||
|
|
d7feb52dd8 | ||
|
|
fafaad8b5f | ||
|
|
928f33005a | ||
|
|
0448d20583 | ||
|
|
f9d408b33f | ||
|
|
9386ae95a3 | ||
|
|
c530e0f7e3 | ||
|
|
e39a5e1ba3 | ||
|
|
cb9e317e74 | ||
|
|
8061ac0ced | ||
|
|
daff8355d7 | ||
|
|
231289e9ac | ||
|
|
87df022af0 | ||
|
|
07fdac9746 | ||
|
|
0d482aaa42 | ||
|
|
d48d1fdde3 | ||
|
|
65b69cc020 | ||
|
|
a3794a6a6a | ||
|
|
695ab84940 | ||
|
|
5399ec67d7 | ||
|
|
d9a6ec65df | ||
|
|
e842de78dd | ||
|
|
260363bf4f | ||
|
|
6d7dffd4de | ||
|
|
ef1d8a8eb4 | ||
|
|
cdd7a1a636 | ||
|
|
6584cd1522 | ||
|
|
d01b602306 | ||
|
|
c90f2c136a | ||
|
|
15ccc7192e | ||
|
|
e4345a4438 | ||
|
|
3f4d564bad | ||
|
|
3311f66da8 | ||
|
|
e711ada0b3 | ||
|
|
840979d635 | ||
|
|
53c9109534 | ||
|
|
87ca6018d3 | ||
|
|
c590087578 | ||
|
|
3963ffa0e2 | ||
|
|
eaa0c9914a | ||
|
|
d9b0b8c33d | ||
|
|
fb4e7e6b55 | ||
|
|
78a063a669 | ||
|
|
cb5d32bbb9 | ||
|
|
f594b734c0 | ||
|
|
bf62b82037 | ||
|
|
39fbb0902e | ||
|
|
7818500f0f | ||
|
|
1ea984b915 | ||
|
|
f28405081a | ||
|
|
317f00e1b8 | ||
|
|
f8ce0b8355 | ||
|
|
9fd6ff9fd2 | ||
|
|
3931020c83 | ||
|
|
8bdb6b0b66 | ||
|
|
33bdee66a3 | ||
|
|
a81f50878e | ||
|
|
322f7d75dd | ||
|
|
2f34da4e99 | ||
|
|
5a9e30b08c | ||
|
|
04e233816d | ||
|
|
3ad1042b13 | ||
|
|
4fb9201823 | ||
|
|
02089e1489 | ||
|
|
da442f4a17 | ||
|
|
a0f8e6fb93 | ||
|
|
20cf5a6132 | ||
|
|
f5ca30620f | ||
|
|
72ec9d81b1 | ||
|
|
29568412e6 | ||
|
|
f94140ec6b | ||
|
|
51062e7403 | ||
|
|
0942bd2548 | ||
|
|
5939fee66f | ||
|
|
68560fd5f5 | ||
|
|
a38b826898 | ||
|
|
0158ccc4c8 | ||
|
|
a5ba6c64c9 | ||
|
|
d67454db9a | ||
|
|
fb35d8b1a0 | ||
|
|
960f75bfba | ||
|
|
83027a5787 | ||
|
|
bc6ef96465 | ||
|
|
ff77dbf3f8 | ||
|
|
3f78fb7a14 | ||
|
|
152904b7c7 | ||
|
|
9214fecc86 | ||
|
|
049397f7ab | ||
|
|
0f42a411a9 | ||
|
|
4575c61118 | ||
|
|
7575a4f883 | ||
|
|
8d4c322bce | ||
|
|
28989f536a | ||
|
|
3318f3501c | ||
|
|
c9ad0c7897 | ||
|
|
018a988124 | ||
|
|
750c370692 | ||
|
|
08ee08c698 | ||
|
|
d899fd4d89 | ||
|
|
aab44d8cee | ||
|
|
2c2fac6e34 | ||
|
|
47ccc54749 | ||
|
|
05d7d0d0d8 | ||
|
|
210fb7b88f | ||
|
|
4eeb7f24d8 | ||
|
|
9496ee1d97 | ||
|
|
f4b05a3236 | ||
|
|
b73763a9ec | ||
|
|
e3aad8c44f | ||
|
|
1e7c298aa2 | ||
|
|
b9652c7cd8 | ||
|
|
04f00d2994 | ||
|
|
3da6728f4c | ||
|
|
5704ca2ae8 | ||
|
|
6cd04e7098 | ||
|
|
290c3560d2 | ||
|
|
40bda3d48b | ||
|
|
63a838dc9d | ||
|
|
127bf7d4e0 | ||
|
|
9af4947232 | ||
|
|
3233a2a078 | ||
|
|
28ede56c7c | ||
|
|
5f6c1f57b0 | ||
|
|
ec704bf501 | ||
|
|
5e0c740f02 | ||
|
|
a185c3abe9 | ||
|
|
e8c6fc635f | ||
|
|
f362db3855 | ||
|
|
9ae584665e | ||
|
|
1aa852c3dc | ||
|
|
ef46ad7a93 | ||
|
|
cbd74d08e1 | ||
|
|
851d0357a0 | ||
|
|
fb5c23d190 | ||
|
|
1743d0e13f | ||
|
|
25af0c4170 | ||
|
|
4bcb77250d | ||
|
|
6114e03cb7 | ||
|
|
9866a787bd | ||
|
|
20e64523b2 | ||
|
|
50a53ec0d1 | ||
|
|
40b2ff5a97 | ||
|
|
48cc000b95 | ||
|
|
75aaf4d784 | ||
|
|
23220ecae6 | ||
|
|
94b4dda94f | ||
|
|
86c5d1a3e9 | ||
|
|
d7be01935f | ||
|
|
b2b647e2f7 | ||
|
|
405cd21c32 | ||
|
|
17a894f5ef | ||
|
|
23c90f4118 | ||
|
|
f215efdcd0 | ||
|
|
2f243787ce | ||
|
|
39909a398e | ||
|
|
5e98a4ff2f | ||
|
|
8710775872 | ||
|
|
99d60eaf9a | ||
|
|
55b7f05685 | ||
|
|
944aa5de66 | ||
|
|
a12eb7efdf | ||
|
|
62a5a2d069 | ||
|
|
ebd65b4fa7 | ||
|
|
152cce2e9e | ||
|
|
ea847a62ff | ||
|
|
2a5ec618f0 | ||
|
|
3fc89377b4 | ||
|
|
93c16e6a62 |
@@ -1,13 +1,8 @@
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected behaviour
|
||||
- Tell us what should happen
|
||||
|
||||
### Actual behaviour
|
||||
- Tell us what happens
|
||||
|
||||
### Environment data
|
||||
|
||||
Audiobookshelf Version:
|
||||
|
||||
- [ ] Android App?
|
||||
- [ ] iOS App?
|
||||
|
||||
#### Android Issue
|
||||
Android version:
|
||||
|
||||
Device model:
|
||||
|
||||
Stock or customized system:
|
||||
|
||||
#### iOS Issue
|
||||
iOS Version:
|
||||
|
||||
iPhone model:
|
||||
@@ -0,0 +1,88 @@
|
||||
name: 🐞 ABS App Bug Report
|
||||
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Bug Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
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.'
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What was the Problem?
|
||||
description: Please help us understand the problem.
|
||||
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
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...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What was Expected?
|
||||
description: Please help us understand what should have happened.
|
||||
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
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
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phone-os
|
||||
attributes:
|
||||
label: Phone OS
|
||||
description: What operating system? If Android, is it stock or a custom system?
|
||||
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
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.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-source
|
||||
attributes:
|
||||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
### Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
### Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,57 @@
|
||||
name: 🚀 App Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
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.'
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the Feature/Enhancement
|
||||
description: Please help us understand what you want.
|
||||
placeholder: What is your vision?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: the-why
|
||||
attributes:
|
||||
label: Why would this be helpful?
|
||||
description: Please help us understand why this would enhance your experience.
|
||||
placeholder: Explain the "why" or "use case".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: image
|
||||
attributes:
|
||||
label: Future Implementation (Screenshot)
|
||||
description: Please help us visualize by including a doodle or screenshot.
|
||||
placeholder: How could this look or where could it go?
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Current Implementation'
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
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.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-image
|
||||
attributes:
|
||||
label: Current Implementation (Screenshot)
|
||||
description: What screen were you looking at when you thought of this enhancement?
|
||||
placeholder: If an image is not applicable, please explain why.
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta http-equiv=content-type content="text/html; charset=utf-8" />
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<title>Audiobookshelf :: Testers</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: rgb(55, 56, 56);
|
||||
font-family: Sans;
|
||||
margin: 40px auto;
|
||||
max-width: 500px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 100;
|
||||
}
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
#apk {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: center;
|
||||
column-gap: 15px;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
background-color: #222;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="https://audiobookshelf.org">
|
||||
<img src=logo.png />
|
||||
</a>
|
||||
<h1>Audiobookshelf :: Testers</h1>
|
||||
|
||||
<p>
|
||||
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
|
||||
The app is automatically built for every new patch added to the project.
|
||||
</p>
|
||||
|
||||
<div id=apk>
|
||||
<div>Built on</div>
|
||||
<div>__DATE__</div>
|
||||
|
||||
<div>Git commit</div>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
|
||||
|
||||
<div>Download</div>
|
||||
<a href="__APK__">__APK__</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Using this requires some technical knowledge to install the APK manually.
|
||||
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
|
||||
<p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- 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: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Close fixed issues on release.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues marked as fixed upon a release.
|
||||
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||
with:
|
||||
label: 'awaiting release'
|
||||
removeLabel: true
|
||||
applyToAll: true
|
||||
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Publish Test App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- 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: 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: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
paths:
|
||||
- strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||
with:
|
||||
directory: 'strings/' # Adjust the directory path as needed
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"vetur.format.defaultFormatterOptions": {
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"prettyhtml": {
|
||||
"printWidth": 400,
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove",
|
||||
"[javascript][json][jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
||||
@@ -18,84 +18,81 @@ 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 108
|
||||
versionName "0.9.77-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 92
|
||||
versionName "0.9.61-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
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()
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
configurations.configureEach {
|
||||
resolutionStrategy {
|
||||
force("com.google.android.gms:play-services-base:18.0.1")
|
||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
|
||||
force("androidx.media:media:$androidx_media_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||
|
||||
// failOnVersionConflict()
|
||||
}
|
||||
}
|
||||
|
||||
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.3'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
// testImplementation "junit:junit:$junitVersion"
|
||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
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"
|
||||
|
||||
@@ -118,22 +115,18 @@ dependencies {
|
||||
|
||||
// Glide for images
|
||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||
|
||||
// Paper NoSQL Db
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
implementation "com.anggrayudi:storage:1.5.6"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||
|
||||
// FFMPEG-Kit
|
||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-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')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Audiobookshelf</string>
|
||||
<string name="title_activity_main">Audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||
<string name="action_jump_forward">Jump Forward</string>
|
||||
<string name="action_jump_backward">Jump Backward</string>
|
||||
<string name="action_skip_forward">Skip Forward</string>
|
||||
<string name="action_skip_backward">Skip Backward</string>
|
||||
<string name="action_change_speed">Change Playback Speed</string>
|
||||
</resources>
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -15,24 +16,27 @@
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true" >
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".NewAppWidget"-->
|
||||
<!-- android:exported="true" >-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />-->
|
||||
<!-- </intent-filter>-->
|
||||
android:usesCleartextTraffic="true"
|
||||
android:largeHeap="true" >
|
||||
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.appwidget.provider"-->
|
||||
<!-- android:resource="@xml/new_app_widget_info" />-->
|
||||
<!-- </receiver>-->
|
||||
<!-- Android app widget -->
|
||||
<receiver
|
||||
android:name="MediaPlayerWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
<meta-data
|
||||
@@ -74,7 +78,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
@@ -83,11 +87,22 @@
|
||||
<service
|
||||
android:name=".player.PlayerNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="true" >
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -2,5 +2,12 @@
|
||||
"appId": "com.audiobookshelf.app",
|
||||
"appName": "audiobookshelf-app",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"androidScheme": "http"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/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"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/browser",
|
||||
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/clipboard",
|
||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
|
||||
@@ -12,10 +12,10 @@ import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
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.getcapacitor.BridgeActivity
|
||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||
return ::foregroundService.isInitialized
|
||||
}
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
|
||||
/**
|
||||
* Implementation of App Widget functionality.
|
||||
*/
|
||||
class MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
Log.d(tag, "onUpdate $appWidgetIds")
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
DbManager.initialize(context)
|
||||
|
||||
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.initializeWidgetUpdater(context)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||
val tag = "MediaPlayerWidget"
|
||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
context,
|
||||
System.currentTimeMillis().toInt(),
|
||||
wholeWidgetClickI,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
|
||||
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
|
||||
|
||||
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
|
||||
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
|
||||
|
||||
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
|
||||
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
|
||||
|
||||
// Show/Hide button container
|
||||
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||
|
||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
}
|
||||
}
|
||||
|
||||
val artist = playbackSession?.displayAuthor ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetArtistText, artist)
|
||||
|
||||
val title = playbackSession?.displayTitle ?: "Unknown"
|
||||
views.setTextViewText(R.id.widgetMediaTitle, title)
|
||||
|
||||
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||
|
||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||
|
||||
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
|
||||
/**
|
||||
* Implementation of App Widget functionality.
|
||||
*/
|
||||
class NewAppWidget : AppWidgetProvider() {
|
||||
val tag = "NewAppWidget"
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
// There may be multiple widgets active, so update all of them
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
updateAppWidget(context, appWidgetManager, appWidgetId, null,false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.w(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
// Enter relevant functionality for when the first widget is created
|
||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns:PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
Log.i(tag, "onPlayerChanged | Is Playing? $isPlaying")
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, NewAppWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
val cover = playbackSession?.getCoverUri()
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, cover, isPlaying)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, coverUri:Uri?, isPlaying:Boolean) {
|
||||
|
||||
val views = RemoteViews(context.packageName, R.layout.new_app_widget)
|
||||
|
||||
val playPausePI = buildMediaButtonPendingIntent(context, ACTION_PLAY_PAUSE)
|
||||
views.setOnClickPendingIntent(R.id.playPauseIcon, playPausePI)
|
||||
|
||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||
context,
|
||||
System.currentTimeMillis().toInt(),
|
||||
wholeWidgetClickI,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
views.setOnClickPendingIntent(R.id.appWidget, wholeWidgetClickPI)
|
||||
|
||||
val imageUri = coverUri ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.imageView, views, appWidgetId) {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
super.onResourceReady(resource, transition)
|
||||
}
|
||||
}
|
||||
|
||||
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||
|
||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
||||
views.setImageViewResource(R.id.playPauseIcon, playPauseResource)
|
||||
|
||||
// Instruct the widget manager to update the widget
|
||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -24,8 +27,9 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||
@JsonIgnore
|
||||
open fun removeAudioTrack(localFileId:String) { }
|
||||
@JsonIgnore
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
||||
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||
@JsonIgnore
|
||||
open fun checkHasTracks():Boolean { return false }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -53,7 +57,7 @@ class Podcast(
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
val localEpisodeId = "local_ep_" + at.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
}
|
||||
}
|
||||
@@ -67,7 +71,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -93,10 +97,15 @@ 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
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -126,6 +135,7 @@ class Book(
|
||||
var audioFiles:List<AudioFile>?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var ebookFile: EBookFile?,
|
||||
var size:Long?,
|
||||
var duration:Double?,
|
||||
var numTracks:Int?
|
||||
@@ -179,7 +189,12 @@ class Book(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun checkHasTracks():Boolean {
|
||||
return (tracks?.size ?: numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +204,7 @@ class Book(
|
||||
JsonSubTypes.Type(BookMetadata::class),
|
||||
JsonSubTypes.Type(PodcastMetadata::class)
|
||||
)
|
||||
open class MediaTypeMetadata(var title:String) {
|
||||
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||
@JsonIgnore
|
||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||
}
|
||||
@@ -208,13 +223,15 @@ class BookMetadata(
|
||||
var isbn:String?,
|
||||
var asin:String?,
|
||||
var language:String?,
|
||||
var explicit:Boolean,
|
||||
explicit:Boolean,
|
||||
// In toJSONExpanded
|
||||
var authorName:String?,
|
||||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
) : MediaTypeMetadata(title) {
|
||||
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" }
|
||||
}
|
||||
@@ -224,8 +241,9 @@ class PodcastMetadata(
|
||||
title:String,
|
||||
var author:String?,
|
||||
var feedUrl:String?,
|
||||
var genres:MutableList<String>
|
||||
) : MediaTypeMetadata(title) {
|
||||
var genres:MutableList<String>,
|
||||
explicit:Boolean
|
||||
) : MediaTypeMetadata(title, explicit) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||
}
|
||||
@@ -250,15 +268,16 @@ data class PodcastEpisode(
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var duration:Double?,
|
||||
var size:Long?,
|
||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
libraryItem.getCoverUri(ctx)
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
@@ -296,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 {
|
||||
@@ -339,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,
|
||||
@@ -384,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)
|
||||
|
||||
@@ -20,6 +20,18 @@ enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
@@ -48,6 +60,20 @@ data class LocalFile(
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
|
||||
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
|
||||
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -69,14 +95,14 @@ data class LocalFolder(
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
@@ -93,8 +119,10 @@ data class PlayItemRequestPayload(
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
@@ -102,7 +130,16 @@ data class DeviceSettings(
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var sleepTimerLength: Long // Time in milliseconds
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -110,8 +147,10 @@ data class DeviceSettings(
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
@@ -119,7 +158,16 @@ data class DeviceSettings(
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L // 15 minutes
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +185,7 @@ data class DeviceSettings(
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
@@ -154,8 +203,8 @@ data class DeviceSettings(
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var currentLocalPlaybackSession: PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings: DeviceSettings?
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class EBookFile(
|
||||
var ino:String,
|
||||
var metadata:FileMetadata?,
|
||||
var ebookFormat:String,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var contentUrl:String?
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -31,17 +32,25 @@ 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()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
@@ -49,53 +58,116 @@ class LibraryItem(
|
||||
|
||||
@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
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): 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
|
||||
if (collapsedSeries == null) {
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
|
||||
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 = localLibraryItemId ?: id
|
||||
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(title)
|
||||
.setTitle(itemTitle)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
.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 {
|
||||
/*
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
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
|
||||
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -45,8 +49,11 @@ class LocalLibraryItem(
|
||||
val isPodcast get() = mediaType == "podcast"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
if (coverContentUrl?.startsWith("file:") == true) {
|
||||
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
|
||||
}
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -73,27 +80,52 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
||||
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
|
||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||
val sessionId = "${UUID.randomUUID()}"
|
||||
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||
var duration = getDuration()
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
chapters = episode.chapters
|
||||
duration = episode.audioTrack?.duration ?: 0.0
|
||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||
}
|
||||
|
||||
val dateNow = System.currentTimeMillis()
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -102,18 +134,16 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
if (coverContentUrl != null) {
|
||||
ctx?.let {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +174,10 @@ class LocalLibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
if (media.metadata.explicit) {
|
||||
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||
}
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
|
||||
@@ -18,6 +18,7 @@ data class LocalMediaItem(
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
@@ -40,9 +41,9 @@ data class LocalMediaItem(
|
||||
@JsonIgnore
|
||||
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())
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ data class LocalMediaItem(
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import kotlin.math.roundToInt
|
||||
@@ -14,6 +13,8 @@ class LocalMediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
@@ -33,6 +34,11 @@ class LocalMediaProgress(
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun isMatch(mediaProgress:MediaProgress):Boolean {
|
||||
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
|
||||
return libraryItemId == mediaProgress.libraryItemId
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateIsFinished(finished:Boolean) {
|
||||
@@ -54,11 +60,20 @@ class LocalMediaProgress(
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
this.ebookProgress = ebookProgress
|
||||
this.ebookLocation = ebookLocation
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
ebookProgress = serverMediaProgress.ebookProgress
|
||||
ebookLocation = serverMediaProgress.ebookLocation
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
|
||||
@@ -12,6 +12,8 @@ class MediaProgress(
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var ebookLocation:String?, // cfi tag
|
||||
var ebookProgress:Double?, // 0 to 1
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
|
||||
@@ -6,8 +6,12 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toFile
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -25,6 +29,7 @@ class PlaybackSession(
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
@@ -135,10 +140,17 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
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())
|
||||
}
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.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)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -150,6 +162,8 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
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)
|
||||
@@ -160,16 +174,16 @@ class PlaybackSession(
|
||||
.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, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
.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, getCoverUri())
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
@@ -180,7 +194,9 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
@@ -189,20 +205,18 @@ class PlaybackSession(
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems():List<MediaItem> {
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
val mediaUri = this.getContentUri(audioTrack)
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
@@ -250,11 +264,11 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun clone():PlaybackSession {
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun syncData(syncData:MediaProgressSyncData) {
|
||||
fun syncData(syncData: MediaProgressSyncData) {
|
||||
timeListening += syncData.timeListened
|
||||
updatedAt = System.currentTimeMillis()
|
||||
currentTime = syncData.currentTime
|
||||
@@ -262,6 +276,6 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MediaPlayerWidget
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
object DeviceManager {
|
||||
@@ -36,6 +44,29 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
// Initialize auto sleep timer auto rewind added in v0.9.64
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
|
||||
// Language added in v0.9.69
|
||||
if (deviceData.deviceSettings?.languageCode == null) {
|
||||
deviceData.deviceSettings?.languageCode = "en-us"
|
||||
}
|
||||
|
||||
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
|
||||
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -46,4 +77,54 @@ object DeviceManager {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
|
||||
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)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
|
||||
fun initializeWidgetUpdater(context:Context) {
|
||||
Log.d(tag, "Initializing widget updater")
|
||||
widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,11 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSObject
|
||||
import org.json.JSONException
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
@@ -26,200 +21,190 @@ class FolderScanner(var ctx: Context) {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
enum class ItemScanResult {
|
||||
ADDED, REMOVED, UPDATED, UPTODATE
|
||||
}
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
||||
FFmpegKitConfig.enableLogCallback { log ->
|
||||
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
|
||||
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
|
||||
return null
|
||||
}
|
||||
|
||||
var mediaItemsUpdated = 0
|
||||
var mediaItemsAdded = 0
|
||||
var mediaItemsRemoved = 0
|
||||
var mediaItemsUpToDate = 0
|
||||
|
||||
// Search for files in media item folder
|
||||
val foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
|
||||
// Remove existing items no longer there
|
||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
||||
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
|
||||
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
||||
if (fileFound == null) {
|
||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
||||
mediaItemsRemoved++
|
||||
}
|
||||
fileFound != null
|
||||
}
|
||||
|
||||
foldersFound.forEach { itemFolder ->
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
||||
|
||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
||||
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||
} else {
|
||||
Log.d(tag, "No Media Items to save")
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
||||
var isNewOrUpdated = existingItem == null
|
||||
|
||||
val audioTracks = mutableListOf<AudioTrack>()
|
||||
val localFiles = mutableListOf<LocalFile>()
|
||||
var index = 1
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
}
|
||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
|
||||
filesInFolder.forEach { file ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
|
||||
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
|
||||
localFiles.add(localFile)
|
||||
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (isAudio) {
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
if (existingAudioTrack != null) { // Update existing audio track
|
||||
if (existingAudioTrack.index != index) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
|
||||
existingAudioTrack.index = index
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingAudioTrack.startOffset != startOffset) {
|
||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
|
||||
existingAudioTrack.startOffset = startOffset
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAudioTrack == null || forceAudioProbe) {
|
||||
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
|
||||
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
if (existingAudioTrack != null) {
|
||||
// Update audio probe data on existing audio track
|
||||
existingAudioTrack.audioProbeResult = audioProbeResult
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
} else {
|
||||
// Create new audio track
|
||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
audioTrackToAdd = track
|
||||
}
|
||||
|
||||
startOffset += audioProbeResult?.duration ?: 0.0
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
}
|
||||
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else {
|
||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null) {
|
||||
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
}
|
||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
||||
// Existing media item did not have a cover - cover found on scan
|
||||
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
|
||||
isNewOrUpdated = true
|
||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
||||
existingItem.coverContentUrl = localFile.contentUrl
|
||||
existingItem.media.coverPath = localFile.absolutePath
|
||||
}
|
||||
|
||||
// First image file use as cover path
|
||||
if (coverContentUrl == null) {
|
||||
coverContentUrl = localFile.contentUrl
|
||||
coverAbsolutePath = localFile.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existingItem != null && audioTracks.isEmpty()) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
||||
return ItemScanResult.REMOVED
|
||||
} else if (existingItem != null && !isNewOrUpdated) {
|
||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
||||
return ItemScanResult.UPTODATE
|
||||
} else if (existingItem != null) {
|
||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
||||
existingItem.updateFromScan(audioTracks,localFiles)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
||||
return ItemScanResult.UPDATED
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
return ItemScanResult.ADDED
|
||||
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)
|
||||
} else {
|
||||
return ItemScanResult.UPTODATE
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
|
||||
if (file == null) {
|
||||
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
||||
} else {
|
||||
if (downloadItemPart.audioTrack != null) {
|
||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
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())
|
||||
// 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
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
downloadItemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
|
||||
} 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()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
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())
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
// If downloading to internal storage handle separately
|
||||
if (downloadItem.isInternalStorage) {
|
||||
scanInternalDownloadItem(downloadItem, cb)
|
||||
return
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
@@ -254,7 +239,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
// 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/octet-stream"))
|
||||
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
|
||||
@@ -271,6 +256,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
@@ -289,7 +275,8 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, 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, null, audioTrackFromServer.index)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||
@@ -301,6 +288,16 @@ class FolderScanner(var ctx: Context) {
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
@@ -311,8 +308,8 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -346,7 +343,9 @@ class FolderScanner(var ctx: Context) {
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -356,6 +355,7 @@ class FolderScanner(var ctx: Context) {
|
||||
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
|
||||
@@ -365,122 +365,4 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
||||
return null
|
||||
}
|
||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
||||
|
||||
var wasUpdated = false
|
||||
|
||||
// Search for files in media item folder
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
||||
|
||||
filesFound.forEach {
|
||||
try {
|
||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
||||
}catch(e:Exception) {
|
||||
Log.d(tag, "Check file found exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||
|
||||
// Remove any files no longer found in library item folder
|
||||
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
||||
existingLocalFileIds.forEach { localFileId ->
|
||||
Log.d(tag, "Checking local file id is there $localFileId")
|
||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||
|
||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||
}
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||
|
||||
val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
if (existingLocalFile == null) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
||||
}
|
||||
|
||||
if (localFile.isAudioFile()) {
|
||||
// TODO: Make asynchronous
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
// Create new audio track
|
||||
val lastTrack = existingAudioTracks.lastOrNull()
|
||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||
localLibraryItem.media.addAudioTrack(track)
|
||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||
wasUpdated = true
|
||||
} else {
|
||||
existingTrack.audioProbeResult = audioProbeResult
|
||||
// TODO: Update data found from probe
|
||||
|
||||
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
||||
|
||||
wasUpdated = true
|
||||
}
|
||||
} else { // Check if cover is empty
|
||||
if (localLibraryItem.coverContentUrl == null) {
|
||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
wasUpdated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpdated) {
|
||||
Log.d(tag, "Local library item was updated - saving it")
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
} else {
|
||||
Log.d(tag, "Local library item was up-to-date")
|
||||
}
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
|
||||
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
|
||||
var probeObject:JSObject? = null
|
||||
try {
|
||||
probeObject = JSObject(session.output)
|
||||
} catch(error:JSONException) {
|
||||
Log.e(tag, "Failed to parse probe result $error")
|
||||
}
|
||||
|
||||
Log.d(tag, "FFprobe output $probeObject")
|
||||
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||
null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
audioProbeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
@@ -237,18 +237,26 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalPlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
|
||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
||||
}
|
||||
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ 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) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 1
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
@@ -43,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
}
|
||||
@@ -64,11 +72,33 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
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 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +118,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let {
|
||||
checkDownloadItemFinished(it)
|
||||
}
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
} else {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
delay(500)
|
||||
@@ -165,7 +206,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
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}")
|
||||
|
||||
@@ -188,7 +228,8 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
if (resultDocFile.name?.endsWith(".m4b.mp3") == true || resultDocFile.name?.endsWith(".m4a.mp3") == true) {
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
@@ -223,23 +264,27 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
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()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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.*
|
||||
|
||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream?, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var readBytes: Int
|
||||
var totalBytes: Long = 0
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes.toLong()
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
progressCallback.onComplete(false)
|
||||
return totalBytes
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 1024
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
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 fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
@@ -54,12 +58,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
@@ -79,13 +84,9 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -96,7 +97,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
@@ -106,11 +107,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
// Start fading out audio
|
||||
val volume = sleepTimeSecondsRemaining / 60F
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,12 +122,20 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
@@ -132,12 +144,21 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when extending sleep timer by shaking
|
||||
private fun vibrate() {
|
||||
// Vibrate when resetting sleep timer
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -187,7 +208,7 @@ 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")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -205,6 +226,17 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
@@ -212,7 +244,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
@@ -222,14 +254,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
// 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")
|
||||
vibrate()
|
||||
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")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -264,7 +296,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
@@ -286,7 +318,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun checkAutoSleepTimer() {
|
||||
@@ -303,18 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||
|
||||
// In cases where end time is earlier then start time then we add a day to end time
|
||||
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
|
||||
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
|
||||
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.
|
||||
// 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 (endCalendar.before(startCalendar)) {
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
} else { // Shift end forward a day
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
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")
|
||||
|
||||
// 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) {
|
||||
@@ -322,19 +367,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
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
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SyncResult
|
||||
|
||||
object MediaEventManager {
|
||||
const val tag = "MediaEventManager"
|
||||
@@ -69,7 +68,7 @@ object MediaEventManager {
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) {
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
@@ -103,8 +102,7 @@ object MediaEventManager {
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.
|
||||
serverConnectionConfigId,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.*
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -19,25 +20,64 @@ 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()
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
|
||||
private var userSettingsPlaybackRate:Float? = null
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
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
|
||||
@@ -61,19 +101,61 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun checkResetServerItems() {
|
||||
fun setSavedPlaybackRate(newRate: Float) {
|
||||
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
val sharedPrefEditor = sharedPrefs.edit()
|
||||
if (sharedPrefs != null) {
|
||||
val userSettingsPref = sharedPrefs.getString("userSettings", null)
|
||||
if (userSettingsPref != null) {
|
||||
try {
|
||||
val userSettings = JSObject(userSettingsPref)
|
||||
// toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d)
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
sharedPrefEditor.apply()
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}")
|
||||
}
|
||||
} else {
|
||||
// Not sure if this is the best place for this, but if a user has not changed any user settings in the app
|
||||
// the object will not exist yet, could be moved to a centralized place or created on first app load
|
||||
val userSettings = JSONObject()
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
Log.d(tag, "Reset caches")
|
||||
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) {
|
||||
@@ -81,34 +163,467 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
cb(serverItemsInProgress)
|
||||
} else {
|
||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||
serverItemsInProgress = itemsInProgress
|
||||
serverItemsInProgress = itemsInProgress.filter {
|
||||
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||
libraryItem.checkHasTracks()
|
||||
}
|
||||
cb(serverItemsInProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ) {
|
||||
serverLibraries.forEach {
|
||||
libraryPersonalizationsDone++
|
||||
Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
||||
populatePersonalizedDataForLibrary(it.id) {
|
||||
Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}")
|
||||
libraryPersonalizationsDone--
|
||||
}
|
||||
}
|
||||
|
||||
while (libraryPersonalizationsDone > 0) { }
|
||||
|
||||
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))
|
||||
@@ -154,8 +669,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 }
|
||||
|
||||
@@ -176,13 +691,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,14 +732,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
if (!apiHandler.isOnline()) {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
|
||||
cb(false)
|
||||
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
@@ -292,6 +814,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")
|
||||
|
||||
@@ -299,37 +840,82 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
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
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]
|
||||
@@ -364,7 +950,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
if (libraryItemWrapper is LocalLibraryItem) {
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
|
||||
} else {
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.audiobookshelf.app.player
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -6,9 +6,8 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.data.LocalMediaProgress
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.data.Podcast
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
@@ -25,7 +24,7 @@ data class SyncResult(
|
||||
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
|
||||
|
||||
@@ -39,7 +38,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
@@ -62,8 +61,8 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
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() {
|
||||
@@ -199,8 +198,6 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
@@ -218,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
@@ -226,32 +222,39 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
currentPlaybackSession?.let {
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.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 (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||
)
|
||||
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||
} 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")
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
@@ -260,7 +263,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (shouldSyncServer) {
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
@@ -268,13 +271,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
@@ -307,6 +311,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -21,6 +21,9 @@ data class DownloadItem(
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
|
||||
@@ -32,7 +35,7 @@ data class DownloadItem(
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size > limit) break
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.EBookFile
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -20,6 +21,7 @@ data class DownloadItemPart(
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val ebookFile: EBookFile?,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
@@ -35,12 +37,12 @@ data class DownloadItemPart(
|
||||
var bytesDownloaded: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
@@ -53,6 +55,7 @@ data class DownloadItemPart(
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
ebookFile = ebookFile,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
@@ -70,6 +73,12 @@ data class DownloadItemPart(
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class User(
|
||||
val id:String,
|
||||
val username: String,
|
||||
val mediaProgress:List<MediaProgress>
|
||||
)
|
||||
@@ -8,14 +8,13 @@ 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
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
@@ -76,15 +75,15 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
var callback = object : ScanCallback() {
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
// if the routes have changed, we may have an available device
|
||||
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true)
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession
|
||||
val session = getSessionManager()?.currentCastSession
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
onRouteUpdate(outRoutes)
|
||||
}
|
||||
|
||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.C.TrackType
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player.*
|
||||
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
|
||||
import com.google.android.exoplayer2.Tracks.Group
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.exoplayer2.source.TrackGroup
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||
import com.google.android.exoplayer2.text.Cue
|
||||
import com.google.android.exoplayer2.text.CueGroup
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var myPlayWhenReady:Boolean = false
|
||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var myAvailableCommands: Commands
|
||||
private var myCurrentTracksInfo: TracksInfo
|
||||
private var myCurrentTracksInfo: Tracks
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACK_INFOS)
|
||||
COMMAND_GET_TRACKS)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
Log.d(tag, "Init CastPlayer")
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
statusListener = StatusListener()
|
||||
timelineTracker = CastTimelineTracker()
|
||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||
listeners.queueEvent(
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
myCurrentTracksInfo = Tracks.EMPTY
|
||||
return hasChanged
|
||||
}
|
||||
var activeTrackIds = mediaStatus.activeTrackIds
|
||||
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
||||
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
|
||||
for (i in castMediaTracks.indices) {
|
||||
val mediaTrack = castMediaTracks[i]
|
||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
||||
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
||||
val trackSelected = booleanArrayOf(selected)
|
||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
||||
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
|
||||
}
|
||||
|
||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val ts = trackSelections.filterNotNull().toTypedArray()
|
||||
val tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val newTrackGroups = TrackGroupArray(*tg)
|
||||
val newTrackSelections = TrackSelectionArray(*ts)
|
||||
val newTracksInfo = TracksInfo(tgi)
|
||||
if (newTrackGroups != currentTrackGroups
|
||||
|| newTrackSelections != currentTrackSelections
|
||||
|| newTracksInfo != currentTracksInfo) {
|
||||
val newTracksInfo = Tracks(tgi)
|
||||
if (newTracksInfo != currentTracks) {
|
||||
myCurrentTrackSelections = newTrackSelections
|
||||
myCurrentTrackGroups = newTrackGroups
|
||||
myCurrentTracksInfo = newTracksInfo
|
||||
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
listeners.flushEvents()
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlayerStateAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
|
||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val statusCode: Int = it.status.statusCode
|
||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
||||
Log.e(tag, "Seek failed. Error code $statusCode")
|
||||
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
|
||||
// We assume the default position is 0. There is no support for seeking to the default position
|
||||
// in RemoteMediaClient.
|
||||
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||
val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||
if (mediaStatus != null) {
|
||||
if (currentMediaItemIndex != mediaItemIndex) {
|
||||
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
||||
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
||||
} else {
|
||||
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
||||
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||
val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
||||
}
|
||||
val oldPosition = getCurrentPositionInfo()
|
||||
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlaybackRateAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
sessionManager.endCurrentSession(false)
|
||||
}
|
||||
|
||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
||||
return this.myCurrentTrackGroups
|
||||
}
|
||||
|
||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
||||
return this.myCurrentTrackSelections
|
||||
}
|
||||
|
||||
override fun getCurrentTracksInfo(): TracksInfo {
|
||||
override fun getCurrentTracks(): Tracks {
|
||||
return this.myCurrentTracksInfo
|
||||
}
|
||||
|
||||
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return VideoSize.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): MutableList<Cue> {
|
||||
return mutableListOf()
|
||||
override fun getSurfaceSize(): Size {
|
||||
return Size.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): CueGroup {
|
||||
return CueGroup.EMPTY_TIME_ZERO
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): DeviceInfo {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
|
||||
@@ -85,7 +86,27 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
playerNotificationService.seekPlayer(pos)
|
||||
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||
}
|
||||
|
||||
private fun onChangeSpeed() {
|
||||
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
|
||||
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||
val mediaManager = playerNotificationService.mediaManager
|
||||
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
|
||||
in 0.5f..0.7f -> 1.0f
|
||||
in 0.8f..1.0f -> 1.2f
|
||||
in 1.1f..1.2f -> 1.5f
|
||||
in 1.3f..1.5f -> 2.0f
|
||||
in 1.6f..2.0f -> 3.0f
|
||||
in 2.1f..3.0f -> 0.5f
|
||||
// anything set above 3 (can happen in the android app) will be reset to 1
|
||||
else -> 1.0f
|
||||
}
|
||||
mediaManager.setSavedPlaybackRate(newSpeed)
|
||||
playerNotificationService.setPlaybackSpeed(newSpeed)
|
||||
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
@@ -95,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
if (mediaId.isNullOrEmpty()) {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
||||
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,17 +160,46 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||
|
||||
// Widget button intent is only sending the action down event
|
||||
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
|
||||
when (keyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||
|
||||
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
|
||||
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
|
||||
// Log.i(tag, "No playback session but had one in the db")
|
||||
//
|
||||
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
|
||||
// connectionConfig?.let {
|
||||
// Log.i(tag, "Setting playback session from db $it")
|
||||
// DeviceManager.serverConnectionConfig = it
|
||||
//
|
||||
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
|
||||
// playerNotificationService.startNewPlaybackSession()
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
playerNotificationService.pause()
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
playerNotificationService.play()
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Rewind")
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,27 +234,9 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
return false
|
||||
@@ -248,6 +281,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_for
|
||||
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||
const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed";
|
||||
|
||||
const val PLAYMETHOD_DIRECTPLAY = 0
|
||||
const val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
lazyIsPlaying = isPlaying
|
||||
|
||||
// Update widget
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
|
||||
if (isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
@@ -74,12 +77,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
if (!shouldCarryOn) return
|
||||
}
|
||||
// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
// if (!shouldCarryOn) return
|
||||
// }
|
||||
|
||||
if (seekBackTime > 0L) {
|
||||
playerNotificationService.seekBackward(seekBackTime)
|
||||
@@ -92,12 +96,15 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (isPlaying) {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
|
||||
playbackSession?.let {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
|
||||
playerNotificationService.mediaProgressSyncer.play(it)
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
@@ -105,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
|
||||
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
|
||||
var tag = "PlayerNotificationListener"
|
||||
|
||||
companion object {
|
||||
var isForegroundService = false
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
onGoing: Boolean) {
|
||||
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
if (onGoing && !isForegroundService) {
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
}
|
||||
isForegroundService = true
|
||||
} else {
|
||||
Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(
|
||||
@@ -28,16 +44,13 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
} else {
|
||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||
|
||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
||||
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
||||
Log.d(tag, "PNS is not closed - closing it now")
|
||||
playerNotificationService.closePlayback()
|
||||
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
||||
if (PlayerNotificationService.isSwitchingPlayer) {
|
||||
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||
// so we don't want to cancel the playback during this switch
|
||||
Log.d(tag, "PNS is switching player")
|
||||
PlayerNotificationService.isSwitchingPlayer = false
|
||||
}
|
||||
}
|
||||
isForegroundService = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
try {
|
||||
initCastManager()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
|
||||
}
|
||||
|
||||
val foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
@@ -63,8 +67,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
|
||||
emit("onSleepTimerSet", sleepTimeRemaining)
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||
val ret = JSObject()
|
||||
ret.put("value", sleepTimeRemaining)
|
||||
ret.put("isAuto", isAutoSleepTimer)
|
||||
notifyListeners("onSleepTimerSet", ret)
|
||||
}
|
||||
|
||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||
@@ -94,6 +101,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
|
||||
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
|
||||
emit("onPlaybackSpeedChanged", playbackSpeed)
|
||||
}
|
||||
})
|
||||
|
||||
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||
@@ -169,7 +180,7 @@ 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")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
@@ -187,10 +198,13 @@ 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)}")
|
||||
val playbackSession = it.getPlaybackSession(episode)
|
||||
val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
playbackSession.currentTime = startTimeOverride
|
||||
@@ -330,7 +344,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
@@ -371,7 +386,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
@@ -216,13 +218,31 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncLocalMediaProgressWithServer(call:PluginCall) {
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
apiHandler.syncMediaProgress {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
|
||||
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()) {
|
||||
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
// Remove all local sessions
|
||||
savedSessions.forEach {
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +273,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
@@ -333,6 +355,8 @@ class AbsDatabase : Plugin() {
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
ebookLocation = null,
|
||||
ebookProgress = null,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
@@ -377,6 +401,55 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalEbookProgress(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
val ebookLocation = call.getString("ebookLocation", "").toString()
|
||||
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
|
||||
|
||||
val localMediaProgressId = localLibraryItemId
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
|
||||
val book = localLibraryItem.media as Book
|
||||
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = null,
|
||||
duration = book.duration ?: 0.0,
|
||||
progress = 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = false,
|
||||
ebookLocation = ebookLocation,
|
||||
ebookProgress = ebookProgress,
|
||||
lastUpdate = System.currentTimeMillis(),
|
||||
startedAt = 0L,
|
||||
finishedAt = null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = null)
|
||||
} else {
|
||||
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
@@ -422,9 +495,16 @@ class AbsDatabase : Plugin() {
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Updates playback actions for media notification (handles media control seek locking setting)
|
||||
if (mainActivity.isPlayerNotificationServiceInitialized()) {
|
||||
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
@@ -51,7 +50,6 @@ class AbsDownloader : Plugin() {
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -59,7 +57,7 @@ class AbsDownloader : Plugin() {
|
||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||
var episodeId = call.data.getString("episodeId").toString()
|
||||
if (episodeId == "null") episodeId = ""
|
||||
val localFolderId = call.data.getString("localFolderId").toString()
|
||||
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
@@ -74,9 +72,18 @@ class AbsDownloader : Plugin() {
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
if (localFolderId == "") {
|
||||
localFolderId = "internal-${libraryItem.mediaType}"
|
||||
}
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder != null) {
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
@@ -102,12 +109,6 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean folder path so it can be used in URL
|
||||
private fun cleanRelPath(relPath: String): String {
|
||||
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
|
||||
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
|
||||
}
|
||||
|
||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
@@ -129,7 +130,9 @@ class AbsDownloader : Plugin() {
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
@@ -140,13 +143,40 @@ class AbsDownloader : Plugin() {
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
book.ebookFile?.let { ebookFile ->
|
||||
val fileSize = ebookFile.metadata?.size ?: 0
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
// Create download item part for each audio track
|
||||
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
|
||||
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
|
||||
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
@@ -163,7 +193,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -188,7 +218,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
@@ -199,14 +229,15 @@ class AbsDownloader : Plugin() {
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val audioTrack = episode?.audioTrack
|
||||
val audioFileIno = episode?.audioFile?.ino
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
||||
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
@@ -217,7 +248,7 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
@@ -233,7 +264,7 @@ class AbsDownloader : Plugin() {
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -13,16 +12,12 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
@@ -165,23 +160,10 @@ class AbsFileSystem : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
||||
|
||||
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||
if (folderScanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
return call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
|
||||
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
fun getSDKVersion(call: PluginCall) {
|
||||
val jsObject = JSObject()
|
||||
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||
call.resolve(jsObject)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -198,27 +180,6 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanLocalLibraryItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.let {
|
||||
val folderScanner = FolderScanner(context)
|
||||
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
if (scanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
call.resolve(JSObject())
|
||||
} else {
|
||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
|
||||
}
|
||||
} ?: call.resolve(JSObject())
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
@@ -226,8 +187,55 @@ class AbsFileSystem : Plugin() {
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
// Check if should delete subfolder
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
|
||||
val success: Boolean
|
||||
|
||||
// If internal library item use File to delete
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
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")
|
||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||
docfilesub?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
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
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
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.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -17,6 +22,7 @@ import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.internal.EMPTY_REQUEST
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
@@ -28,69 +34,69 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>, val serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int, var serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
|
||||
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
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)
|
||||
}
|
||||
|
||||
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun isOnline(): 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)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
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)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isUsingCellularData(): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
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")
|
||||
@@ -135,9 +141,21 @@ class ApiHandler(var ctx:Context) {
|
||||
})
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getCurrentUser Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val user = jacksonMapper.readValue<User>(it.toString())
|
||||
cb(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -154,6 +172,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")) {
|
||||
@@ -194,6 +229,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>()
|
||||
@@ -251,59 +383,6 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
||||
if (!isOnline()) {
|
||||
Log.d(tag, "Error not online")
|
||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get all local media progress connected to items on the current connected server
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
|
||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||
}
|
||||
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
|
||||
|
||||
if (localMediaProgress.isNotEmpty()) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||
postRequest("/api/me/sync-local-progress", payload, null) {
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||
|
||||
if (it.toString() == "{}") {
|
||||
Log.e(tag, "Progress sync received empty object")
|
||||
} else if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Progress sync error")
|
||||
} else {
|
||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||
|
||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
||||
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
|
||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
||||
// Update all local media progress
|
||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
}
|
||||
}
|
||||
|
||||
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
|
||||
}
|
||||
}
|
||||
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
|
||||
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
@@ -355,6 +434,13 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlaybackSession(playbackSessionId:String, config:ServerConnectionConfig?, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||
postRequest("/api/session/$playbackSessionId/close", null, config) {
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||
postRequest("/api/authorize", JSObject(), config) {
|
||||
@@ -375,4 +461,71 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
@SuppressLint("HardwareIds")
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
Log.e(tag, "Failed to sync local sessions")
|
||||
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")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||
// 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")
|
||||
return cb()
|
||||
}
|
||||
|
||||
getCurrentUser { _user ->
|
||||
_user?.let { user->
|
||||
// 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}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-50.96875Q7,-69.375,10.6875,-80.84375Q14.390625,-92.328125,21.671875,-98.15625Q28.953125,-104,40,-104Q48.140625,-104,54.53125,-100.625Q60.9375,-97.265625,64.6875,-90.921875Q68.4375,-84.59375,70.71875,-75.5Q73,-66.40625,73,-50.96875Q73,-32.703125,69.34375,-21.21875Q65.6875,-9.75,58.390625,-3.875Q51.109375,2,40,2Q25.359375,2,17.015625,-8.265625Q7,-21.71875,7,-50.96875ZM20,-51Q20,-25.828125,25.765625,-17.90625Q31.53125,-10,40,-10Q48.46875,-10,54.234375,-17.953125Q60,-25.90625,60,-51Q60,-75.734375,54.359375,-83.859375Q48.71875,-92,39.859375,-92Q31.40625,-92,26.09375,-84.828125Q20,-75.671875,20,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M192,-13L192,0L124,0Q123.859375,-4.75,125.421875,-9.125Q127.921875,-16.359375,133.4375,-23.25Q138.96875,-30.140625,149.64062,-38.6875Q166.21875,-52.625,172.10938,-60.28125Q178,-67.9375,178,-75.015625Q178,-82.171875,172.70312,-87.078125Q167.40625,-92,158.875,-92Q149.875,-92,144.46875,-86.953125Q139.0625,-81.921875,139,-73L126,-75Q127.328125,-89.15625,135.95312,-96.578125Q144.59375,-104,159.15625,-104Q173.85938,-104,182.42188,-95.71875Q191,-87.4375,191,-75.203125Q191,-68.96875,188.59375,-62.953125Q186.20312,-56.9375,180.64062,-50.28125Q175.09375,-43.640625,161.73438,-32.375Q150.35938,-23.03125,146.96875,-19.6875Q143.59375,-16.359375,141.39062,-13L192,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M72,-13L72,-0L4,-0Q3.859375,-4.75,5.421875,-9.125Q7.921875,-16.359375,13.4375,-23.25Q18.96875,-30.140625,29.640625,-38.6875Q46.21875,-52.625,52.109375,-60.28125Q58,-67.9375,58,-75.015625Q58,-82.171875,52.703125,-87.078125Q47.40625,-92,38.875,-92Q29.875,-92,24.46875,-86.953125Q19.0625,-81.921875,19,-73L6,-75Q7.328125,-89.15625,15.953125,-96.578125Q24.59375,-104,39.15625,-104Q53.859375,-104,62.421875,-95.71875Q71,-87.4375,71,-75.203125Q71,-68.96875,68.59375,-62.953125Q66.203125,-56.9375,60.640625,-50.28125Q55.09375,-43.640625,41.734375,-32.375Q30.359375,-23.03125,26.96875,-19.6875Q23.59375,-16.359375,21.390625,-13L72,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-27L20,-29Q22.109375,-18.90625,26.90625,-14.453125Q31.71875,-10,39.484375,-10Q48.609375,-10,54.296875,-16Q60,-22.015625,60,-30.90625Q60,-39.375,54.5,-44.890625Q49,-50.40625,40.515625,-50.40625Q37.046875,-50.40625,31.890625,-49L33.25,-61Q34.46875,-60.859375,35.203125,-60.859375Q42.8125,-60.859375,48.90625,-64.796875Q55,-68.75,55,-76.953125Q55,-83.46875,50.484375,-87.734375Q45.984375,-92,38.84375,-92Q31.765625,-92,27.046875,-87.75Q22.34375,-83.5,21,-75L8,-77Q10.28125,-89.859375,18.34375,-96.921875Q26.40625,-104,38.40625,-104Q46.6875,-104,53.65625,-100.390625Q60.625,-96.796875,64.3125,-90.578125Q68,-84.359375,68,-77.359375Q68,-70.71875,64.40625,-65.265625Q60.828125,-59.8125,53.796875,-56.59375Q62.9375,-54.453125,67.96875,-47.6875Q73,-40.9375,73,-30.796875Q73,-17.078125,63.375,-7.53125Q53.75,2,39.03125,2Q25.765625,2,17,-6.078125Q8.234375,-14.15625,7,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1 @@
|
||||
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>
|
||||
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 560 B |
|
After Width: | Height: | Size: 798 B |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 311 B |
|
After Width: | Height: | Size: 303 B |
|
After Width: | Height: | Size: 315 B |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 532 B |
|
Before Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 664 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 707 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 895 B |