Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
84632c8a32 | ||
|
|
df717fc87f | ||
|
|
2b9f5c866b | ||
|
|
2de81130a1 | ||
|
|
2942d703ee | ||
|
|
3ef6c345e4 | ||
|
|
0a4052dec2 | ||
|
|
ef4bb6c7a8 | ||
|
|
d9c38d1592 | ||
|
|
a7227acbc3 | ||
|
|
bd5229941b | ||
|
|
0583ce617e | ||
|
|
7462eb3bc4 | ||
|
|
18e15ff056 | ||
|
|
4f6468c23a | ||
|
|
31531197a2 | ||
|
|
479de5f067 | ||
|
|
03240415b8 | ||
|
|
f8a6b6a936 | ||
|
|
28e50dd461 | ||
|
|
f06afbd08c | ||
|
|
860c7aac80 | ||
|
|
8bab4ae383 | ||
|
|
69171e5732 | ||
|
|
31abaa019f | ||
|
|
ad57a90e4d | ||
|
|
925e8fd17e | ||
|
|
7b857f04d4 | ||
|
|
453744a637 | ||
|
|
062d4dc069 | ||
|
|
21bd98b367 | ||
|
|
ccd206e95d | ||
|
|
bca5421dbc | ||
|
|
20085c5eed | ||
|
|
a8c66ff808 | ||
|
|
b2d3edca81 | ||
|
|
4b7fa7a543 | ||
|
|
a018dcf085 | ||
|
|
091c1bcb9f | ||
|
|
d89c97ba1a | ||
|
|
56fe350104 | ||
|
|
eb552170e3 | ||
|
|
29d022adcc | ||
|
|
d9afdaf56f | ||
|
|
699976dcd8 | ||
|
|
acf85f4f09 | ||
|
|
8f6dd72df2 | ||
|
|
297eca6a86 | ||
|
|
b1805875b9 | ||
|
|
16b06b5eb8 | ||
|
|
dbbb5faa78 | ||
|
|
5482b30324 | ||
|
|
5bae5411c8 | ||
|
|
f75a8dfb86 | ||
|
|
716d07c74e | ||
|
|
7de0304a3d | ||
|
|
df54094221 | ||
|
|
3207841384 | ||
|
|
ae571d6b79 | ||
|
|
f6ee0bf8df | ||
|
|
d59f3ae0b6 | ||
|
|
89041c4141 | ||
|
|
aac77949b4 | ||
|
|
eb40f6e6e9 | ||
|
|
34b3910ff8 | ||
|
|
83fdf4fd65 | ||
|
|
ea6bb0a847 | ||
|
|
d85158bd70 | ||
|
|
22c5fee48e | ||
|
|
0890a44c0b | ||
|
|
4fd4cc5604 | ||
|
|
c73b71345a | ||
|
|
11f0af1ebd | ||
|
|
a532f988b0 | ||
|
|
bec2796771 | ||
|
|
feec7f7399 | ||
|
|
067c2f84dd | ||
|
|
69996a4346 | ||
|
|
80cda129d2 | ||
|
|
dd0ff04155 | ||
|
|
c69558aa7f | ||
|
|
8fefea0d94 | ||
|
|
0ddee6f44e | ||
|
|
feec1ab55a | ||
|
|
49500aecd6 | ||
|
|
894bf100d2 | ||
|
|
7ba810adb8 | ||
|
|
d10f33362e | ||
|
|
82e8fbba2b | ||
|
|
64325fe2a6 | ||
|
|
99217fee48 | ||
|
|
f12e64990c | ||
|
|
ef7bb9a9d8 | ||
|
|
5ac925e151 | ||
|
|
acc5bde33a | ||
|
|
b521f37ec1 | ||
|
|
379fa21571 | ||
|
|
1aa6a441f3 | ||
|
|
705bbaccc1 | ||
|
|
8365e06762 | ||
|
|
139a0b3412 | ||
|
|
ea6b21169e | ||
|
|
a262efe9da | ||
|
|
ed5fb09007 | ||
|
|
d3c6429fd6 | ||
|
|
371fcacc87 | ||
|
|
75666e523f | ||
|
|
592ea7c533 | ||
|
|
b860dfc791 | ||
|
|
96dde8cf31 | ||
|
|
b4a37fed28 | ||
|
|
ad3fee8907 | ||
|
|
94db55598a | ||
|
|
9687f47b6b | ||
|
|
af216d9b38 | ||
|
|
c7879c2bc0 | ||
|
|
7db503a2cf | ||
|
|
d72295a0e6 | ||
|
|
638da4400f | ||
|
|
af9a7aafae | ||
|
|
ab9f7fed64 | ||
|
|
b62ce27487 | ||
|
|
6d9e902fe7 | ||
|
|
a0faf3f7d4 | ||
|
|
32dfb3b40a | ||
|
|
6bbcd300e4 | ||
|
|
c54d9b3c5f | ||
|
|
9ab03fbcd4 | ||
|
|
a852114e9c | ||
|
|
3c9b8c1c6f | ||
|
|
4735b51dc0 | ||
|
|
23927a1d59 | ||
|
|
40b592f7c1 | ||
|
|
4da31f5850 | ||
|
|
f79e96cbfe | ||
|
|
2dbce1e3bb | ||
|
|
cdfbbcbf8a | ||
|
|
890852bef5 | ||
|
|
3b285db21a | ||
|
|
d37be88977 | ||
|
|
e6d27a1487 | ||
|
|
5d23c17d30 | ||
|
|
9c9c1c3b94 | ||
|
|
4e94fd6ad0 | ||
|
|
81a8203cca | ||
|
|
b6b206c288 | ||
|
|
ed2310a2e7 | ||
|
|
bae5d97087 | ||
|
|
3e12ff139b | ||
|
|
7ccd8db085 | ||
|
|
fdc829d06c | ||
|
|
e7f61e34e8 | ||
|
|
141877f111 | ||
|
|
b71a976e61 | ||
|
|
68c95ed6d5 | ||
|
|
7a68cc99af | ||
|
|
7294bc7033 | ||
|
|
f8836be147 | ||
|
|
713999eb83 | ||
|
|
cc4c9787c0 | ||
|
|
ecc5cf3e1b | ||
|
|
ba7d2be03e | ||
|
|
368f349c78 | ||
|
|
639d641c07 | ||
|
|
d9313a1654 | ||
|
|
9dd532285c | ||
|
|
92f10fdb5c | ||
|
|
9d0f43a664 | ||
|
|
453365fcd6 | ||
|
|
6231c5fcc4 | ||
|
|
9821ddabcf | ||
|
|
57777f2f4b | ||
|
|
1f2c309d8c | ||
|
|
c11977870d | ||
|
|
41753cd853 | ||
|
|
003285f567 | ||
|
|
d1c1902cd3 | ||
|
|
b697deac1c | ||
|
|
fb8e6408bb | ||
|
|
836ffddd4f | ||
|
|
c8ff5a7817 | ||
|
|
c14f6ec4c2 | ||
|
|
dc8852eb0d | ||
|
|
586405f9de | ||
|
|
34e68593bc | ||
|
|
c02adfa326 | ||
|
|
021d63d973 | ||
|
|
50f6b15d23 | ||
|
|
6257c6488b | ||
|
|
defd895995 | ||
|
|
82159ee537 | ||
|
|
11f22888d5 | ||
|
|
20d932877e | ||
|
|
35631fb211 | ||
|
|
b02dd18e0b | ||
|
|
b583a8e6e2 | ||
|
|
5775a51731 | ||
|
|
9b94a11ab9 | ||
|
|
9ee1203130 | ||
|
|
711b079963 | ||
|
|
91dbd73d45 | ||
|
|
a2ac0e29e4 | ||
|
|
4271d832f1 | ||
|
|
64147d065c | ||
|
|
fb03c8e67f | ||
|
|
75979960ab | ||
|
|
1411157bde | ||
|
|
eb7289c150 | ||
|
|
c1f803bdd0 | ||
|
|
ba1efedd79 | ||
|
|
ac10997ed7 | ||
|
|
3c6f29bf3a | ||
|
|
f623393f8d | ||
|
|
66ab402a50 | ||
|
|
2076b93e19 | ||
|
|
a5aae3fce8 | ||
|
|
eb7a241e94 | ||
|
|
14ff9097c7 | ||
|
|
79f09b334c | ||
|
|
f5d1e992ef | ||
|
|
aed2c31f5a | ||
|
|
3e31e72734 | ||
|
|
7cf36d829a | ||
|
|
268cf67576 | ||
|
|
5c76158729 | ||
|
|
8c87b31e56 | ||
|
|
01678f2c91 | ||
|
|
b5e33b1707 | ||
|
|
bba388aefa | ||
|
|
46623d70a3 | ||
|
|
452b25057e | ||
|
|
67a6aec132 | ||
|
|
10ddc1c911 | ||
|
|
2a2ebefeb9 | ||
|
|
d5f39e5cb1 | ||
|
|
099be648bf | ||
|
|
6a885b7241 | ||
|
|
15cdff5aa2 | ||
|
|
8bd0e863ae | ||
|
|
94e261d7bf | ||
|
|
06f87d24a7 | ||
|
|
cdd2f40ef9 | ||
|
|
2448b461f0 | ||
|
|
4f85827585 | ||
|
|
fe042f3f83 | ||
|
|
d084958f2d | ||
|
|
ccecba7a19 | ||
|
|
d57fe44bcc | ||
|
|
a32bd9d74c | ||
|
|
39250b56c0 | ||
|
|
1c85e21e4f | ||
|
|
d331e1f9ab | ||
|
|
d7c414f6c0 | ||
|
|
523f861f77 | ||
|
|
8a0a6f383f | ||
|
|
b8b2d73eab | ||
|
|
683b4e753a | ||
|
|
a3f2b02473 | ||
|
|
befe62f8a0 | ||
|
|
621a493c5b | ||
|
|
8952cbfd20 | ||
|
|
a576eff700 | ||
|
|
506e18a137 | ||
|
|
27d2ed2304 | ||
|
|
a670ba9f95 | ||
|
|
2321312427 | ||
|
|
85382d2bb4 | ||
|
|
7745ccefab | ||
|
|
872931f507 | ||
|
|
f335d8735f | ||
|
|
fca0fd7bbb | ||
|
|
a461ed43de | ||
|
|
0f8672fb92 | ||
|
|
972fbd42ee | ||
|
|
062a217946 | ||
|
|
a59f3bb957 | ||
|
|
93cb27d324 | ||
|
|
b4e70da1f5 | ||
|
|
bf46c46fc0 | ||
|
|
af835f2c43 | ||
|
|
ffd0d4da7d | ||
|
|
e9226671c9 | ||
|
|
5fd3f3c080 | ||
|
|
519969eee0 | ||
|
|
1746ed6469 | ||
|
|
2912d442b5 | ||
|
|
a82474cf45 | ||
|
|
56a3d8bb72 | ||
|
|
add64249cd | ||
|
|
d3172ece9b | ||
|
|
f655f89c49 | ||
|
|
911e46c4f0 | ||
|
|
ec4647e2bc | ||
|
|
10f2da9e90 | ||
|
|
6aa0f2253b | ||
|
|
7c5ee940d3 | ||
|
|
d83e04c47b | ||
|
|
b0905d0270 | ||
|
|
8ce0d9ce56 | ||
|
|
ac2a1ce079 | ||
|
|
5d31499fd6 | ||
|
|
4c8217edf6 | ||
|
|
934a07a5ad | ||
|
|
7aae1edf3c | ||
|
|
7cc35e4b96 | ||
|
|
1f8a8d1ce5 | ||
|
|
e0e2f02e0b | ||
|
|
c029e519e9 | ||
|
|
ef661bba37 | ||
|
|
8d38f3358e | ||
|
|
f4e39ec7ca | ||
|
|
72744dcd29 | ||
|
|
6b374d923b | ||
|
|
03a47b06ee | ||
|
|
77d0974782 | ||
|
|
95e070a897 | ||
|
|
c32c77b963 | ||
|
|
9477860bca | ||
|
|
a5f483f518 | ||
|
|
65fcc45bc6 | ||
|
|
2e6946e1f7 | ||
|
|
64707b0928 | ||
|
|
a107a25e43 | ||
|
|
0a989e7811 | ||
|
|
4e63d43679 | ||
|
|
60cb5f398c | ||
|
|
8edeefc1cd | ||
|
|
02eabb82c1 | ||
|
|
999d434fe9 | ||
|
|
2b07c81e54 | ||
|
|
5f4ff38035 | ||
|
|
e52a5fd585 | ||
|
|
599a8e23cb | ||
|
|
81a4e4ff7b | ||
|
|
7d730dcafb | ||
|
|
446e54cb91 | ||
|
|
b9f428fd77 | ||
|
|
94e8978ce6 | ||
|
|
62376871e3 | ||
|
|
40b731534c | ||
|
|
1aea0a18a9 | ||
|
|
b1be594932 | ||
|
|
a9d7fbc083 | ||
|
|
a3e458fcc4 | ||
|
|
c1b8f8519f | ||
|
|
34d4a0b61b | ||
|
|
7c3b6c9702 | ||
|
|
e275aa1699 | ||
|
|
e9961f64a9 | ||
|
|
8e2be4704e | ||
|
|
948cd3068a | ||
|
|
162eb9afad | ||
|
|
30ae98c3de | ||
|
|
b1b10e846d | ||
|
|
56c73c12db | ||
|
|
ad802c16ea | ||
|
|
afc04d22bf | ||
|
|
b78f004f29 | ||
|
|
849b6303e8 | ||
|
|
4a82ba7c86 | ||
|
|
5b7fcca800 | ||
|
|
b549528e23 | ||
|
|
719db8d3f6 | ||
|
|
db98492d4f | ||
|
|
5430c10424 | ||
|
|
5495bcb945 | ||
|
|
7fded5e105 | ||
|
|
dc8bc2fccd | ||
|
|
af2c609405 | ||
|
|
d5d65e244b | ||
|
|
92a31c246c | ||
|
|
9082b76707 | ||
|
|
33041608f8 | ||
|
|
408faf3665 | ||
|
|
be6c675ded | ||
|
|
528854b285 | ||
|
|
87e277fad1 | ||
|
|
014588180e | ||
|
|
e02edb9f9e | ||
|
|
37fa931672 | ||
|
|
a7424cc428 | ||
|
|
837df329e2 | ||
|
|
f6c43e479d | ||
|
|
0b46a9c9b1 | ||
|
|
76de92fe1f | ||
|
|
32550a75ec | ||
|
|
9eca03cfd7 | ||
|
|
fec1ec554b | ||
|
|
db7a8cef77 | ||
|
|
5662c1983a | ||
|
|
8cfefdcc0f | ||
|
|
7d91b95320 | ||
|
|
beb790ad17 | ||
|
|
073110376a | ||
|
|
16f6f3b7e9 | ||
|
|
4758f3e557 | ||
|
|
38cc2adf0f | ||
|
|
80fbbfc2ef | ||
|
|
ca45956479 | ||
|
|
3d64c42b14 | ||
|
|
97e7a12883 | ||
|
|
e4d2b646e7 | ||
|
|
a5a8f3b6ac | ||
|
|
41b0535213 | ||
|
|
a146a157b8 | ||
|
|
96fff469fc | ||
|
|
a60ff00f7e | ||
|
|
d52c326123 | ||
|
|
76882780a4 | ||
|
|
97eb19da86 | ||
|
|
4ed9d702e9 | ||
|
|
4c678836fb | ||
|
|
fd134097a1 | ||
|
|
2a87f1de28 | ||
|
|
1f60a552e5 | ||
|
|
3b34029ca2 | ||
|
|
5f6a1ef7e9 | ||
|
|
b51f65d2a4 | ||
|
|
dc9526beb3 | ||
|
|
7188089f63 | ||
|
|
2ab9dbc72d | ||
|
|
52d0890032 | ||
|
|
b7725c455b | ||
|
|
db1d6da491 | ||
|
|
329a51f886 | ||
|
|
0c0badbae4 | ||
|
|
2da272c270 | ||
|
|
25ec5b3dcb | ||
|
|
d9cdc16ff9 | ||
|
|
066b72d54d | ||
|
|
e7c913643a | ||
|
|
9847ed9fcb | ||
|
|
721f812df0 | ||
|
|
a58965306a | ||
|
|
2b5377db04 | ||
|
|
7fe3849705 | ||
|
|
f998deb725 | ||
|
|
175e642081 | ||
|
|
e620f53705 | ||
|
|
f56561b898 | ||
|
|
a29a7669e1 | ||
|
|
29f08a5e5d | ||
|
|
f40da562a6 | ||
|
|
affcdea09b | ||
|
|
16ca7460f4 | ||
|
|
cb2aaede67 | ||
|
|
2ca9ce797d | ||
|
|
e07e7f70d6 | ||
|
|
48313b60c1 | ||
|
|
3be13025a8 | ||
|
|
505717e580 | ||
|
|
b4bb6bf81b | ||
|
|
6a8d901ce1 | ||
|
|
68f3f0e276 | ||
|
|
ddf8e66a08 | ||
|
|
5b7a492a48 | ||
|
|
9be1ee1843 | ||
|
|
b5c6acc2bc | ||
|
|
39b137c7f1 | ||
|
|
b16a7342aa | ||
|
|
3056c55d2f | ||
|
|
6b416e6fc1 | ||
|
|
e4c6093a82 | ||
|
|
3c1120ea48 | ||
|
|
a41e26e4c6 | ||
|
|
317dc366e3 | ||
|
|
cd4c280950 | ||
|
|
0ca8de5916 | ||
|
|
3840f2f344 | ||
|
|
f353a44fac | ||
|
|
a06796d9ca | ||
|
|
a69054fefa | ||
|
|
6805d7eb96 | ||
|
|
c77d0f1944 | ||
|
|
c457c8eaf7 | ||
|
|
a391db5dc2 | ||
|
|
2180e2f649 | ||
|
|
fc6d16bdd9 | ||
|
|
815529129a | ||
|
|
bad8d10a18 | ||
|
|
a4412da3ed | ||
|
|
870774b408 | ||
|
|
d7dcaa22a6 | ||
|
|
cc0b2943dd | ||
|
|
50ee0b2265 | ||
|
|
910b3a2a17 | ||
|
|
916da91ccb | ||
|
|
d70a99254a | ||
|
|
dcf5bb61a2 | ||
|
|
aa65bb10c1 | ||
|
|
158448f8a2 | ||
|
|
f4be9b3e26 | ||
|
|
15d68ca285 | ||
|
|
ad12e6a19d | ||
|
|
1f1b2fe85a | ||
|
|
480df58ce4 | ||
|
|
2decf532b2 | ||
|
|
3ba87419ae | ||
|
|
1c78af37fa | ||
|
|
2b5373aedd | ||
|
|
99bf960b8a | ||
|
|
c4aca22c28 | ||
|
|
58bd0e0cee | ||
|
|
c1c56f8f52 | ||
|
|
f930ba1941 | ||
|
|
251116a5ce | ||
|
|
06739c0401 | ||
|
|
92f069d1e6 | ||
|
|
62c5042ecc | ||
|
|
604b086c0b | ||
|
|
0e0b356f6b | ||
|
|
573768e2b2 | ||
|
|
236fd09c94 | ||
|
|
cb6cb5f637 | ||
|
|
eb916a3ab0 | ||
|
|
61b8ca1510 | ||
|
|
5d2da97dc5 | ||
|
|
d626686614 | ||
|
|
60ee33cb72 | ||
|
|
8d2498e96d | ||
|
|
1794484bed | ||
|
|
f2b6331843 | ||
|
|
8d5f33245f | ||
|
|
1ee544b842 | ||
|
|
0fd9463c7c | ||
|
|
ad5edf3aee | ||
|
|
d5fafd8cab | ||
|
|
92256f2fed | ||
|
|
5fa8d4c989 | ||
|
|
6b75f79f00 | ||
|
|
b0c9f29d90 | ||
|
|
7721afc116 | ||
|
|
bc85e9bbfa | ||
|
|
e8b6602fe5 | ||
|
|
0c49bcfe3f | ||
|
|
aecb6a8dd2 | ||
|
|
718947522e | ||
|
|
2f8ca51447 | ||
|
|
4fa6dd2616 | ||
|
|
736e57fafd | ||
|
|
30d86279a5 | ||
|
|
8bbfcdeb82 | ||
|
|
d23cf62264 | ||
|
|
73d5b19d2b | ||
|
|
1959351125 | ||
|
|
0708133779 | ||
|
|
9a81fc3688 | ||
|
|
ac71d39265 | ||
|
|
4203654ec8 | ||
|
|
9701c767b2 | ||
|
|
0223df4f9e | ||
|
|
7549385404 | ||
|
|
3f2d0ed8b1 | ||
|
|
e4a5927e07 | ||
|
|
a3aac4da75 | ||
|
|
1e9453e501 | ||
|
|
bec3f5841e | ||
|
|
068762912f | ||
|
|
01e85d0e91 | ||
|
|
a67c19f30f | ||
|
|
394363c8cb | ||
|
|
7fd51ebcc1 | ||
|
|
15b7fbce47 | ||
|
|
e03f878865 | ||
|
|
cffa7f5344 | ||
|
|
8411428241 | ||
|
|
ae4678cf24 | ||
|
|
68e565ebe2 | ||
|
|
d99f4406b7 | ||
|
|
2064cd8380 | ||
|
|
415ff65561 | ||
|
|
be885009ad | ||
|
|
d64dd63ea4 | ||
|
|
72f10e5f42 | ||
|
|
511386e80a | ||
|
|
b219756cb7 | ||
|
|
31c753ffcc | ||
|
|
2dd822e04d | ||
|
|
d18972e2f3 | ||
|
|
81ca757c77 | ||
|
|
7189588c2b | ||
|
|
1fed00ca81 | ||
|
|
a63022a669 | ||
|
|
843f6ca606 | ||
|
|
52fd8ac5e8 | ||
|
|
d5b69be7c1 | ||
|
|
729e959506 | ||
|
|
a0a7fd1e14 | ||
|
|
0b4c999018 | ||
|
|
c5a9677ac6 | ||
|
|
dbe0b7ea69 | ||
|
|
32df099e8a | ||
|
|
612c48174c | ||
|
|
e8e7a241c6 | ||
|
|
9263028c56 | ||
|
|
114dbd24bc | ||
|
|
4d0d1eb88f | ||
|
|
b72aba36b8 | ||
|
|
9d6a467517 | ||
|
|
441bc24e2d | ||
|
|
033f066568 | ||
|
|
49ad1ef601 | ||
|
|
a17b588e29 | ||
|
|
f65f7b01c3 | ||
|
|
379612d874 | ||
|
|
25bccdfce6 | ||
|
|
f974fc6e54 | ||
|
|
4d71bc48aa | ||
|
|
df1ce6d91f | ||
|
|
ae65bed352 | ||
|
|
2f8e30accb | ||
|
|
13665af0ed | ||
|
|
c4c6377925 | ||
|
|
493d7aecc9 | ||
|
|
f7516889e4 | ||
|
|
760d05cf1b | ||
|
|
3f2b3f2e9c | ||
|
|
6cea0ba03d | ||
|
|
066050700c | ||
|
|
448f7855af | ||
|
|
ae195e7b58 | ||
|
|
03c3f37e1e | ||
|
|
363bfd206c | ||
|
|
29fd29728a | ||
|
|
9a7b2e8172 | ||
|
|
0339f731ea | ||
|
|
7fbc2dddbe | ||
|
|
7a9f6ff2ca | ||
|
|
6c65c8a33e | ||
|
|
736af85855 | ||
|
|
4abd1c8b49 | ||
|
|
ccba8dc3c7 | ||
|
|
331a3b4a1a | ||
|
|
24d124e2e8 | ||
|
|
021538820a | ||
|
|
0bc8b32ef5 | ||
|
|
239938e069 | ||
|
|
6db2c7805d | ||
|
|
eed8a29c9b | ||
|
|
6e8d84496b | ||
|
|
782f11ff28 | ||
|
|
6de4626239 | ||
|
|
a0ee82bb31 | ||
|
|
c0ac3b7bb5 | ||
|
|
2f4b3050fd | ||
|
|
bf3a97523d | ||
|
|
c94e57f55e | ||
|
|
ef65b4c278 | ||
|
|
e5c8d5d4d4 | ||
|
|
abf140bd21 | ||
|
|
12a153d423 | ||
|
|
d9e4469089 | ||
|
|
526fca98b9 | ||
|
|
9ad351f0d7 | ||
|
|
f309e1fcf2 | ||
|
|
105451ebf1 | ||
|
|
119bfd6c98 | ||
|
|
ee942c6704 | ||
|
|
12de187b7a | ||
|
|
77ef0c119b | ||
|
|
cc744bb975 | ||
|
|
4b834cb5c1 | ||
|
|
9fd3dc6978 | ||
|
|
4f8b13b23d | ||
|
|
0b9b0341b5 | ||
|
|
f57f0e4e0d | ||
|
|
7a091dd428 | ||
|
|
f70f707100 | ||
|
|
314dc960f2 | ||
|
|
94b9dbb8b3 | ||
|
|
a259883979 | ||
|
|
a8de03b82d | ||
|
|
4fc70cd3dd | ||
|
|
f98aea71cb | ||
|
|
461733854a | ||
|
|
b849882664 | ||
|
|
03312390cb | ||
|
|
4f1f0e4db7 | ||
|
|
73602a51ce | ||
|
|
40b9d4a012 | ||
|
|
2d1e4b9836 | ||
|
|
e998a03571 | ||
|
|
d0b7a958aa | ||
|
|
d8d02984fd | ||
|
|
84bab5de1b | ||
|
|
37b5554b39 | ||
|
|
704318071e | ||
|
|
4d4d3730bd | ||
|
|
61433ef678 | ||
|
|
926c17543f | ||
|
|
f76128c2b3 | ||
|
|
a42f94e367 | ||
|
|
f9be51184d | ||
|
|
52ec8b4234 | ||
|
|
2b692309fa | ||
|
|
6d8479ac7d | ||
|
|
999315ca91 | ||
|
|
f5088d0384 | ||
|
|
979b74f516 | ||
|
|
9253a9bf6e | ||
|
|
69127944a3 | ||
|
|
fd4974dfce | ||
|
|
06f4c933ca | ||
|
|
a4ce1dbe1d |
@@ -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,47 @@
|
||||
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 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- 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,76 @@
|
||||
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 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- 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,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
Audiobookshelf Self-hosted audiobook server
|
||||
Copyright (C) 2021 advplyr
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
Audiobookshelf Copyright (C) 2021 advplyr
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,304 +0,0 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { Storage } from '@capacitor/storage'
|
||||
import axios from 'axios'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
class Server extends EventEmitter {
|
||||
constructor(store) {
|
||||
super()
|
||||
|
||||
this.store = store
|
||||
|
||||
this.url = null
|
||||
this.socket = null
|
||||
|
||||
this.user = null
|
||||
this.connected = false
|
||||
this.initialized = false
|
||||
|
||||
this.stream = null
|
||||
|
||||
this.isConnectingSocket = false
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.user ? this.user.token : null
|
||||
}
|
||||
|
||||
getAxiosConfig() {
|
||||
return { headers: { Authorization: `Bearer ${this.token}` } }
|
||||
}
|
||||
|
||||
getServerUrl(url) {
|
||||
if (!url) return null
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
|
||||
} catch (error) {
|
||||
console.error('Invalid URL', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.user = user
|
||||
this.store.commit('user/setUser', user)
|
||||
if (user) {
|
||||
// this.store.commit('user/setSettings', user.settings)
|
||||
Storage.set({ key: 'token', value: user.token })
|
||||
} else {
|
||||
Storage.remove({ key: 'token' })
|
||||
}
|
||||
}
|
||||
|
||||
setServerUrl(url) {
|
||||
this.url = url
|
||||
this.store.commit('setServerUrl', url)
|
||||
|
||||
if (url) {
|
||||
Storage.set({ key: 'serverUrl', value: url })
|
||||
} else {
|
||||
Storage.remove({ key: 'serverUrl' })
|
||||
}
|
||||
}
|
||||
|
||||
async connect(url, token) {
|
||||
if (this.connected) {
|
||||
console.warn('[SOCKET] Connection already established for ' + this.url)
|
||||
return { success: true }
|
||||
}
|
||||
if (!url) {
|
||||
console.error('Invalid url to connect')
|
||||
return {
|
||||
error: 'Invalid URL'
|
||||
}
|
||||
}
|
||||
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
var res = await this.ping(serverUrl)
|
||||
|
||||
if (!res || !res.success) {
|
||||
return {
|
||||
error: res ? res.error : 'Unknown Error'
|
||||
}
|
||||
}
|
||||
var authRes = await this.authorize(serverUrl, token)
|
||||
if (!authRes || authRes.error) {
|
||||
return {
|
||||
error: authRes ? authRes.error : 'Authorization Error'
|
||||
}
|
||||
}
|
||||
|
||||
this.setServerUrl(serverUrl)
|
||||
|
||||
this.setUser(authRes.user)
|
||||
this.connectSocket()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async check(url) {
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
if (!serverUrl) {
|
||||
return {
|
||||
error: 'Invalid server url'
|
||||
}
|
||||
}
|
||||
var res = await this.ping(serverUrl)
|
||||
if (!res || res.error) {
|
||||
return {
|
||||
error: res ? res.error : 'Ping Failed'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
serverUrl
|
||||
}
|
||||
}
|
||||
|
||||
async login(url, username, password) {
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
var authUrl = serverUrl + '/login'
|
||||
return axios.post(authUrl, { username, password }).then((res) => {
|
||||
if (!res.data || !res.data.user) {
|
||||
console.error(res.data.error)
|
||||
return {
|
||||
error: res.data.error || 'Unknown Error'
|
||||
}
|
||||
}
|
||||
|
||||
this.setServerUrl(serverUrl)
|
||||
this.setUser(res.data.user)
|
||||
this.connectSocket()
|
||||
return {
|
||||
user: res.data.user
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setUser(null)
|
||||
this.stream = null
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
}
|
||||
this.emit('logout')
|
||||
}
|
||||
|
||||
authorize(serverUrl, token) {
|
||||
var authUrl = serverUrl + '/api/authorize'
|
||||
return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => {
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ping(url) {
|
||||
var pingUrl = url + '/ping'
|
||||
console.log('[Server] Check server', pingUrl)
|
||||
return axios.get(pingUrl, { timeout: 1000 }).then((res) => {
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
console.error('Server check failed', error)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
connectSocket() {
|
||||
if (this.socket && !this.connected) {
|
||||
this.socket.connect()
|
||||
console.log('[SOCKET] Submitting connect')
|
||||
return
|
||||
}
|
||||
if (this.connected || this.socket) {
|
||||
if (this.socket) console.error('[SOCKET] Socket already established', this.url)
|
||||
else console.error('[SOCKET] Already connected to socket', this.url)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[SOCKET] Connect Socket', this.url)
|
||||
|
||||
const socketOptions = {
|
||||
transports: ['websocket'],
|
||||
upgrade: false,
|
||||
// reconnectionAttempts: 3
|
||||
}
|
||||
this.socket = io(this.url, socketOptions)
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[SOCKET] Socket Connected ' + this.socket.id)
|
||||
|
||||
// Authenticate socket with token
|
||||
this.socket.emit('auth', this.token)
|
||||
this.connected = true
|
||||
this.emit('connected', true)
|
||||
this.store.commit('setSocketConnected', true)
|
||||
})
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[SOCKET] Socket Disconnected: ' + reason)
|
||||
this.connected = false
|
||||
this.emit('connected', false)
|
||||
this.emit('initialized', false)
|
||||
this.initialized = false
|
||||
this.store.commit('setSocketConnected', false)
|
||||
|
||||
// this.socket.removeAllListeners()
|
||||
// if (this.socket.io && this.socket.io.removeAllListeners) {
|
||||
// console.log(`[SOCKET] Removing ALL IO listeners`)
|
||||
// this.socket.io.removeAllListeners()
|
||||
// }
|
||||
})
|
||||
this.socket.on('init', (data) => {
|
||||
console.log('[SOCKET] Initial socket data received', data)
|
||||
if (data.stream) {
|
||||
this.stream = data.stream
|
||||
this.store.commit('setStreamAudiobook', data.stream.audiobook)
|
||||
this.emit('initialStream', data.stream)
|
||||
}
|
||||
if (data.serverSettings) {
|
||||
this.store.commit('setServerSettings', data.serverSettings)
|
||||
}
|
||||
this.initialized = true
|
||||
this.emit('initialized', true)
|
||||
})
|
||||
|
||||
this.socket.on('user_updated', (user) => {
|
||||
if (this.user && user.id === this.user.id) {
|
||||
this.setUser(user)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('current_user_audiobook_update', (payload) => {
|
||||
this.emit('currentUserAudiobookUpdate', payload)
|
||||
})
|
||||
|
||||
this.socket.on('show_error_toast', (payload) => {
|
||||
this.emit('show_error_toast', payload)
|
||||
})
|
||||
this.socket.on('show_success_toast', (payload) => {
|
||||
this.emit('show_success_toast', payload)
|
||||
})
|
||||
|
||||
this.socket.onAny((evt, args) => {
|
||||
console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
|
||||
})
|
||||
|
||||
this.socket.on('connect_error', (err) => {
|
||||
console.error('[SOCKET] connection failed', err)
|
||||
this.emit('socketConnectionFailed', err)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_attempt", (attempt) => {
|
||||
console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_error", (err) => {
|
||||
console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_failed", () => {
|
||||
console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect", () => {
|
||||
console.log(`[SOCKET] Reconnect Success ${this.socket.id}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Server
|
||||
@@ -4,8 +4,30 @@ plugins {
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
kotlin {
|
||||
kotlinDaemonJvmArgs = [
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
]
|
||||
}
|
||||
|
||||
android {
|
||||
kotlinOptions {
|
||||
|
||||
|
||||
namespace 'com.audiobookshelf.app'
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
}
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
@@ -13,8 +35,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 60
|
||||
versionName "0.9.40-beta"
|
||||
versionCode 94
|
||||
versionName "0.9.63-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -23,6 +45,12 @@ android {
|
||||
}
|
||||
}
|
||||
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'
|
||||
@@ -37,23 +65,33 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.anggrayudi:storage:0.13.0"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
// 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:1.6.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
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"
|
||||
@@ -61,6 +99,7 @@ dependencies {
|
||||
|
||||
implementation "androidx.media:media:$androidx_media_version"
|
||||
|
||||
// Exo Player
|
||||
if (findProject(':exoplayer-library-core') != null) {
|
||||
implementation project(':exoplayer-library-core')
|
||||
implementation project(':exoplayer-library-ui')
|
||||
@@ -75,9 +114,24 @@ dependencies {
|
||||
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
|
||||
}
|
||||
|
||||
|
||||
// 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"
|
||||
|
||||
// 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,21 +2,19 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capacitor-storage')
|
||||
implementation project(':robingenz-capacitor-app-update')
|
||||
implementation project(':capacitor-data-storage-sqlite')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 879 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -1,81 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.audiobookshelf.app">
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="preferExternal" >
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
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"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
android:usesCleartextTraffic="true" >
|
||||
|
||||
<!-- Android app widget -->
|
||||
<receiver
|
||||
android:name="MediaPlayerWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
|
||||
<!--Used by Android Auto-->
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/icon" />
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<!-- Support for Cast -->
|
||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/icon" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.audiobookshelf.app.CastOptionsProvider" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name="com.audiobookshelf.app.MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask">
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Register URL scheme -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="@string/custom_url_scheme" />
|
||||
</intent-filter>
|
||||
<!-- Register URL scheme -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="@string/custom_url_scheme" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".PlayerNotificationService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".player.PlayerNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-community/sqlite",
|
||||
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
@@ -11,24 +7,20 @@
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/haptics",
|
||||
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/network",
|
||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/preferences",
|
||||
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/status-bar",
|
||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/storage",
|
||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@robingenz/capacitor-app-update",
|
||||
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "capacitor-data-storage-sqlite",
|
||||
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.io.File
|
||||
|
||||
|
||||
@CapacitorPlugin(name = "AudioDownloader")
|
||||
class AudioDownloader : Plugin() {
|
||||
private val tag = "AudioDownloader"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
lateinit var downloadManager:DownloadManager
|
||||
|
||||
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
|
||||
// fun toJSObject() : JSObject {
|
||||
// var obj = JSObject()
|
||||
// obj.put("uri", this.uri)
|
||||
// obj.put("name", this.name)
|
||||
// obj.put("size", this.size)
|
||||
// obj.put("coverUrl", this.coverUrl)
|
||||
// return obj
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
|
||||
if (evt == "complete") {
|
||||
}
|
||||
if (evt == "clicked") {
|
||||
Log.d(tag, "Clicked $id back in the audiodownloader")
|
||||
}
|
||||
}
|
||||
mainActivity.registerBroadcastReceiver(recieverEvent)
|
||||
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
|
||||
// @PluginMethod
|
||||
// fun load(call: PluginCall) {
|
||||
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
|
||||
// var len = audiobookUrls?.length()
|
||||
// if (len == null) {
|
||||
// len = 0
|
||||
// }
|
||||
// Log.d(tag, "CALLED LOAD $len")
|
||||
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
|
||||
//
|
||||
// (0 until len).forEach {
|
||||
// var jsobj = audiobookUrls.get(it) as JSONObject
|
||||
// var audiobookUrl = jsobj.get("contentUrl").toString()
|
||||
// var coverUrl = jsobj.get("coverUrl").toString()
|
||||
// var storageId = ""
|
||||
// if(jsobj.has("storageId")) jsobj.get("storageId").toString()
|
||||
//
|
||||
// var basePath = ""
|
||||
// if(jsobj.has("basePath")) jsobj.get("basePath").toString()
|
||||
//
|
||||
// var coverBasePath = ""
|
||||
// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
|
||||
//
|
||||
// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
|
||||
//
|
||||
// var audiobookFile: DocumentFile? = null
|
||||
// var coverFile: DocumentFile? = null
|
||||
//
|
||||
// // Android 9 OR Below use storage id and base path
|
||||
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
|
||||
// if (coverUrl != null && coverUrl != "") {
|
||||
// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
|
||||
// }
|
||||
// } else {
|
||||
// // Android 10 and up manually deleting will still load the file causing crash
|
||||
// var exists = checkUriExists(Uri.parse(audiobookUrl))
|
||||
// if (exists) {
|
||||
// Log.d(tag, "Audiobook exists")
|
||||
// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
|
||||
// } else {
|
||||
// Log.e(tag, "Audiobook does not exist")
|
||||
// }
|
||||
//
|
||||
// var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
// if (coverExists) {
|
||||
// Log.d(tag, "Cover Exists")
|
||||
// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
// } else if (coverUrl != null && coverUrl != "") {
|
||||
// Log.e(tag, "Cover does not exist")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (audiobookFile == null) {
|
||||
// Log.e(tag, "Audiobook was not found $audiobookUrl")
|
||||
// } else {
|
||||
// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
|
||||
//
|
||||
// var _name = audiobookFile.name
|
||||
// if (_name == null) _name = ""
|
||||
//
|
||||
// var size = audiobookFile.length()
|
||||
//
|
||||
// if (audiobookFile.uri.toString() !== audiobookUrl) {
|
||||
// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
|
||||
// }
|
||||
//
|
||||
// // Use existing URI's - bug happening where new uri is different from initial
|
||||
// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
|
||||
//
|
||||
// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
|
||||
//
|
||||
// audiobookItems.add(abItem)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
|
||||
//
|
||||
// var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
|
||||
// var mediaItemNoticePayload = JSObject()
|
||||
// mediaItemNoticePayload.put("items", audiobookObjs)
|
||||
// notifyListeners("onMediaLoaded", mediaItemNoticePayload)
|
||||
// }
|
||||
|
||||
@PluginMethod
|
||||
fun download(call: PluginCall) {
|
||||
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
|
||||
var url = call.data.getString("downloadUrl", "unknown").toString()
|
||||
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
|
||||
var title = call.data.getString("title", "Audiobook").toString()
|
||||
var filename = call.data.getString("filename", "audiobook.mp3").toString()
|
||||
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
|
||||
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
|
||||
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
|
||||
|
||||
var dlfilename = audiobookId + "." + File(filename).extension
|
||||
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
|
||||
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
|
||||
|
||||
var canWriteToFolder = folder.canWrite()
|
||||
if (!canWriteToFolder) {
|
||||
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
|
||||
val ret = JSObject()
|
||||
ret.put("error", "Cannot write to ${folder.baseName}")
|
||||
call.resolve(ret)
|
||||
return
|
||||
}
|
||||
|
||||
var dlRequest = DownloadManager.Request(Uri.parse(url))
|
||||
dlRequest.setTitle("Ab: $title")
|
||||
dlRequest.setDescription("Downloading to ${folder.name}")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
|
||||
|
||||
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
|
||||
var coverDownloadId:Long? = null
|
||||
|
||||
if (coverDownloadUrl != "") {
|
||||
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
|
||||
coverDlRequest.setTitle("Cover: $title")
|
||||
coverDlRequest.setDescription("Downloading to ${folder.name}")
|
||||
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
|
||||
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
|
||||
coverDownloadId = downloadManager.enqueue(coverDlRequest)
|
||||
}
|
||||
|
||||
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("progress", prog)
|
||||
notifyListeners("onDownloadProgress", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
var coverDocFile:DocumentFile? = null
|
||||
|
||||
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
|
||||
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
|
||||
var docfile:DocumentFile? = null
|
||||
|
||||
// Download was complete, now find downloaded file
|
||||
if (id == coverDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
|
||||
Log.d(tag, "Move Cover File ${docfile?.name}")
|
||||
|
||||
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
|
||||
// check if this was the case
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
|
||||
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
|
||||
Log.d(tag, "Move Audiobook File ${docfile?.name}")
|
||||
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
|
||||
Log.d(tag, "File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for moving the downloaded file
|
||||
var callback = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode:ErrorCode) {
|
||||
Log.e(tag, "FAILED MOVE FILE $errorCode")
|
||||
|
||||
docfile?.delete()
|
||||
coverDocFile?.delete()
|
||||
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("error", "Move failed")
|
||||
notifyListeners("onDownloadFailed", jsobj)
|
||||
}
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
var resultDocFile = result as DocumentFile
|
||||
var simplePath = resultDocFile.getSimplePath(context)
|
||||
var storageId = resultDocFile.getStorageId(context)
|
||||
var size = resultDocFile.length()
|
||||
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
|
||||
|
||||
var abFolder = folder.findFolder(title)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("downloadId", id)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", resultDocFile.getStorageType(context))
|
||||
jsobj.put("folderUrl", abFolder?.uri)
|
||||
jsobj.put("folderName", abFolder?.name)
|
||||
jsobj.put("downloadFolderUrl", downloadFolderUrl)
|
||||
jsobj.put("contentUrl", resultDocFile.uri)
|
||||
jsobj.put("basePath", resultDocFile.getBasePath(context))
|
||||
jsobj.put("filename", filename)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("size", size)
|
||||
|
||||
if (resultDocFile.name == filename) {
|
||||
Log.d(tag, "Audiobook Finishing Moving")
|
||||
} else if (resultDocFile.name == coverFilename) {
|
||||
coverDocFile = docfile
|
||||
Log.d(tag, "Audiobook Cover Finished Moving")
|
||||
jsobj.put("isCover", true)
|
||||
}
|
||||
notifyListeners("onDownloadComplete", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
|
||||
if (id == coverDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
|
||||
}
|
||||
}
|
||||
|
||||
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
|
||||
progressUpdater.run()
|
||||
if (coverDownloadId != null) {
|
||||
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
|
||||
coverProgressUpdater.run()
|
||||
}
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("audiobookDownloadId", audiobookDownloadId)
|
||||
ret.put("coverDownloadId", coverDownloadId)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
|
||||
private val query: DownloadManager.Query = DownloadManager.Query()
|
||||
private var totalBytes: Int = 0
|
||||
private var TAG = "DownloadProgressUpdater"
|
||||
|
||||
init {
|
||||
query.setFilterById(this.downloadId)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
Log.d(TAG, "RUN FOR ID $downloadId")
|
||||
var keepRunning = true
|
||||
var increment = 0
|
||||
while (keepRunning) {
|
||||
Thread.sleep(500)
|
||||
increment++
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "Loop $increment : $downloadId")
|
||||
}
|
||||
|
||||
manager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
//get total bytes of the file
|
||||
if (totalBytes <= 0) {
|
||||
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
if (totalBytes <= 0) {
|
||||
Log.e(TAG, "Download Is 0 Bytes $downloadId")
|
||||
doneReceiver(downloadId, false)
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
|
||||
}
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
doneReceiver(downloadId, true)
|
||||
} else {
|
||||
doneReceiver(downloadId, false)
|
||||
}
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
} else {
|
||||
//update progress
|
||||
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
|
||||
receiver(downloadId, percentProgress)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "NOT FOUND IN QUERY")
|
||||
keepRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Audiobook {
|
||||
var id:String
|
||||
var ino:String
|
||||
var libraryId:String
|
||||
var folderId:String
|
||||
var book:Book
|
||||
var duration:Float
|
||||
var size:Long
|
||||
var numTracks:Int
|
||||
var isMissing:Boolean
|
||||
var isInvalid:Boolean
|
||||
var path:String
|
||||
|
||||
var isDownloaded:Boolean = false
|
||||
var downloadFolderUrl:String = ""
|
||||
var folderUrl:String = ""
|
||||
var contentUrl:String = ""
|
||||
var filename:String = ""
|
||||
var localCoverUrl:String = ""
|
||||
var localCover:String = ""
|
||||
|
||||
var serverUrl:String = ""
|
||||
var token:String = ""
|
||||
|
||||
constructor(jsobj: JSObject, serverUrl:String, token:String) {
|
||||
this.serverUrl = serverUrl
|
||||
this.token = token
|
||||
|
||||
id = jsobj.getString("id", "").toString()
|
||||
ino = jsobj.getString("ino", "").toString()
|
||||
libraryId = jsobj.getString("libraryId", "").toString()
|
||||
folderId = jsobj.getString("folderId", "").toString()
|
||||
|
||||
var bookJsObj = jsobj.getJSObject("book")
|
||||
book = bookJsObj?.let { Book(it) }!!
|
||||
|
||||
duration = jsobj.getDouble("duration").toFloat()
|
||||
size = jsobj.getLong("size")
|
||||
numTracks = jsobj.getInteger("numTracks")!!
|
||||
isMissing = jsobj.getBoolean("isMissing")
|
||||
isInvalid = jsobj.getBoolean("isInvalid")
|
||||
path = jsobj.getString("path", "").toString()
|
||||
|
||||
isDownloaded = jsobj.getBoolean("isDownloaded")
|
||||
if (isDownloaded) {
|
||||
downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString()
|
||||
folderUrl = jsobj.getString("folderUrl", "").toString()
|
||||
contentUrl = jsobj.getString("contentUrl", "").toString()
|
||||
filename = jsobj.getString("filename", "").toString()
|
||||
localCover = jsobj.getString("localCover", "").toString()
|
||||
localCoverUrl = jsobj.getString("localCoverUrl", "").toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCover():Uri {
|
||||
if (isDownloaded) {
|
||||
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse(localCoverUrl)
|
||||
}
|
||||
if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
|
||||
}
|
||||
|
||||
fun getDurationLong():Long {
|
||||
return duration.toLong() * 1000L
|
||||
}
|
||||
|
||||
fun toMediaMetadata():MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL)
|
||||
|
||||
// val extras = Bundle()
|
||||
// if (isDownloaded) {
|
||||
// extras.putLong(
|
||||
// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
// MediaDescriptionCompat.STATUS_DOWNLOADED)
|
||||
// }
|
||||
// extras.putInt(
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
|
||||
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI +
|
||||
// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
|
||||
import okhttp3.*
|
||||
import org.json.JSONArray
|
||||
import java.io.IOException
|
||||
|
||||
class AudiobookManager {
|
||||
var tag = "AudiobookManager"
|
||||
|
||||
interface OnStreamData {
|
||||
fun onStreamReady(asd:AudiobookStreamData)
|
||||
}
|
||||
|
||||
var hasLoaded = false
|
||||
var isLoading = false
|
||||
var ctx: Context
|
||||
var serverUrl = ""
|
||||
var token = ""
|
||||
private var client:OkHttpClient
|
||||
|
||||
var localMediaManager:LocalMediaManager
|
||||
|
||||
var audiobooks:MutableList<Audiobook> = mutableListOf()
|
||||
var audiobooksInProgress:MutableList<Audiobook> = mutableListOf()
|
||||
|
||||
var storageSharedPreferences: SharedPreferences? = null
|
||||
|
||||
constructor(_ctx:Context, _client:OkHttpClient) {
|
||||
ctx = _ctx
|
||||
client = _client
|
||||
|
||||
localMediaManager = LocalMediaManager(ctx)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString()
|
||||
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
|
||||
token = storageSharedPreferences?.getString("token", "").toString()
|
||||
Log.d(tag, "SHARED PREF TOKEN $token")
|
||||
}
|
||||
|
||||
fun getPlaybackRate() : Float {
|
||||
if (storageSharedPreferences != null) {
|
||||
var userSettings = storageSharedPreferences?.getString("userSettings", "").toString()
|
||||
if (userSettings != "") {
|
||||
var json = JSObject(userSettings)
|
||||
var playbackRate = json.getString("playbackRate", "1")
|
||||
if (playbackRate != null) {
|
||||
return playbackRate.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun loadCategories(cb: (() -> Unit)) {
|
||||
Log.d(tag, "LOAD Categories $serverUrl | $token")
|
||||
var url = "$serverUrl/api/libraries/main/categories"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var results = JSONArray(bodyString)
|
||||
// var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalShelves = results.length() - 1
|
||||
Log.d(tag, "Got categories $totalShelves")
|
||||
for (i in 0..totalShelves) {
|
||||
var shelfobj = results.get(i)
|
||||
var jsobj = JSObject(shelfobj.toString())
|
||||
var shelfId = jsobj.getString("id", "")
|
||||
Log.d(tag, "Category shelf id $shelfId")
|
||||
if (shelfId == "continue-reading") {
|
||||
var entities = jsobj.getJSONArray("entities")
|
||||
var totalEntities = entities.length() - 1
|
||||
Log.d(tag, "Shelf total entities $totalEntities")
|
||||
for (y in 0..totalEntities) {
|
||||
var abobj = entities.get(y)
|
||||
Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}")
|
||||
var abjsobj = JSObject(abobj.toString())
|
||||
abjsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(abjsobj, serverUrl, token)
|
||||
if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Not an audiobook or invalid/missing")
|
||||
} else {
|
||||
var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooksInProgress.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadAudiobooks(cb: (() -> Unit)) {
|
||||
Log.d(tag, "Load Audiobooks: $serverUrl | $token")
|
||||
if (serverUrl == "" || token == "") {
|
||||
Log.d(tag, "Load Audiobooks: No Server or Token set")
|
||||
cb()
|
||||
return
|
||||
} else if (!serverUrl.startsWith("http")) {
|
||||
Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
|
||||
cb()
|
||||
return
|
||||
}
|
||||
|
||||
// First load currently reading
|
||||
loadCategories() {
|
||||
// Then load all
|
||||
var url = "$serverUrl/api/libraries/main/books/all?sort=book.title"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var resJson = JSObject(bodyString)
|
||||
var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalBooks = results.length() - 1
|
||||
for (i in 0..totalBooks) {
|
||||
var abobj = results.get(i)
|
||||
var jsobj = JSObject(abobj.toString())
|
||||
|
||||
jsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(jsobj, serverUrl, token)
|
||||
|
||||
if (audiobook.isMissing || audiobook.isInvalid) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid")
|
||||
} else if (audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks")
|
||||
} else {
|
||||
var audiobookExists = audiobooks.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooks.add(audiobook)
|
||||
} else {
|
||||
Log.d(tag, "Audiobook already there from downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooks.size} Audiobooks Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
isLoading = true
|
||||
hasLoaded = true
|
||||
|
||||
localMediaManager.loadLocalAudio()
|
||||
|
||||
// Load downloads from sql db
|
||||
var db = CapacitorDataStorageSqlite(ctx)
|
||||
db.openStore("storage", "downloads", false, "no-encryption", 1)
|
||||
var keyvalues = db.keysvalues()
|
||||
keyvalues.forEach {
|
||||
Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}")
|
||||
|
||||
var dlobj = JSObject(it.getString("value"))
|
||||
if (dlobj.has("audiobook")) {
|
||||
var abobj = dlobj.getJSObject("audiobook")!!
|
||||
abobj.put("isDownloaded", true)
|
||||
abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString())
|
||||
abobj.put("filename", dlobj.getString("filename", "").toString())
|
||||
abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString())
|
||||
abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString())
|
||||
abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString())
|
||||
abobj.put("localCover", dlobj.getString("cover", "").toString())
|
||||
|
||||
var audiobook = Audiobook(abobj, serverUrl, token)
|
||||
audiobooks.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openStream(audiobook:Audiobook, streamListener:OnStreamData) {
|
||||
var url = "$serverUrl/api/books/${audiobook.id}/stream"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var stream = JSObject(bodyString)
|
||||
var streamId = stream.getString("streamId", "").toString()
|
||||
var startTime = stream.getDouble("startTime")
|
||||
var streamUrl = stream.getString("streamUrl", "").toString()
|
||||
|
||||
var startTimeLong = (startTime * 1000).toLong()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", streamId)
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", token)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", startTimeLong)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", false)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Log.d(tag, "Stream Ready on Main Looper")
|
||||
streamListener.onStreamReady(audiobookStreamData)
|
||||
}
|
||||
|
||||
Log.d(tag, "Init Player Stream")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "download")
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("contentUrl", audiobook.contentUrl)
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "local")
|
||||
abStreamDataObj.put("audiobookId", local.id)
|
||||
abStreamDataObj.put("contentUrl", local.uri.toString())
|
||||
abStreamDataObj.put("title", local.name)
|
||||
abStreamDataObj.put("author", "")
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", local.coverUri)
|
||||
abStreamDataObj.put("duration", local.duration)
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", 1)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
||||
var cost = Array(lhsLength) { it }
|
||||
var newCost = Array(lhsLength) { 0 }
|
||||
|
||||
for (i in 1..rhsLength-1) {
|
||||
newCost[0] = i
|
||||
|
||||
for (j in 1..lhsLength-1) {
|
||||
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
|
||||
|
||||
val costReplace = cost[j - 1] + match
|
||||
val costInsert = cost[j] + 1
|
||||
val costDelete = newCost[j - 1] + 1
|
||||
|
||||
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
|
||||
}
|
||||
|
||||
val swap = cost
|
||||
cost = newCost
|
||||
newCost = swap
|
||||
}
|
||||
|
||||
return cost[lhsLength - 1]
|
||||
}
|
||||
|
||||
fun searchForAudiobook(query:String):Audiobook? {
|
||||
var closestDistance = 99
|
||||
var closestMatch:Audiobook? = null
|
||||
audiobooks.forEach {
|
||||
var dist = levenshtein(it.book.title, query)
|
||||
Log.d(tag, "LEVENSHTEIN $dist")
|
||||
if (dist < closestDistance) {
|
||||
closestDistance = dist
|
||||
closestMatch = it
|
||||
}
|
||||
}
|
||||
if (closestMatch != null) {
|
||||
Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance")
|
||||
if (closestDistance < 2) {
|
||||
return closestMatch
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getFirstAudiobook():Audiobook? {
|
||||
if (audiobooks.isEmpty()) return null
|
||||
return audiobooks[0]
|
||||
}
|
||||
|
||||
fun getFirstLocal(): LocalMediaManager.LocalAudio? {
|
||||
if (localMediaManager.localAudioFiles.isEmpty()) return null
|
||||
return localMediaManager.localAudioFiles[0]
|
||||
}
|
||||
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getDownloadedAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/*
|
||||
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
|
||||
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
|
||||
*/
|
||||
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
|
||||
private val tag = "AudiobookProgressSync"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
private val client:OkHttpClient = client
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
|
||||
private var webviewOpenOnStart:Boolean = false
|
||||
private var webviewClosedMidSession:Boolean = false
|
||||
private var listeningBookTitle:String? = ""
|
||||
private var listeningBookIsLocal:Boolean = false
|
||||
private var listeningBookId:String? = ""
|
||||
private var listeningStreamId:String? = ""
|
||||
|
||||
private var lastPlaybackTime:Long = 0
|
||||
private var lastUpdateTime:Long = 0
|
||||
|
||||
fun start() {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $listeningBookTitle")
|
||||
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
|
||||
Log.d(tag, "start: Changed audiobook stream - resetting timer")
|
||||
listeningTimerTask?.cancel()
|
||||
}
|
||||
}
|
||||
listeningTimerRunning = true
|
||||
|
||||
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
|
||||
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
|
||||
listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal()
|
||||
listeningBookId = playerNotificationService.getCurrentBookId()
|
||||
listeningStreamId = playerNotificationService.getCurrentStreamId()
|
||||
|
||||
lastPlaybackTime = playerNotificationService.getCurrentTime()
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
// Webview was closed while android auto is open - switch to native sync
|
||||
var isWebviewOpen = playerNotificationService.getIsWebviewOpen()
|
||||
if (!isWebviewOpen && webviewOpenOnStart) {
|
||||
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
|
||||
webviewOpenOnStart = false
|
||||
webviewClosedMidSession = true
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
} else if (isWebviewOpen && webviewClosedMidSession) {
|
||||
Log.d(tag, "Listening Timer: webview re-opened Switching back to webview sync tracking")
|
||||
webviewClosedMidSession = false
|
||||
webviewOpenOnStart = true
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
}
|
||||
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!listeningTimerRunning) return
|
||||
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
|
||||
|
||||
if (!webviewOpenOnStart) {
|
||||
sync()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
listeningBookTitle = ""
|
||||
listeningBookId = ""
|
||||
listeningBookIsLocal = false
|
||||
listeningStreamId = ""
|
||||
}
|
||||
|
||||
fun sync() {
|
||||
var currTime = System.currentTimeMillis() / 1000L
|
||||
var elapsed = currTime - lastUpdateTime
|
||||
lastUpdateTime = currTime
|
||||
|
||||
if (!listeningBookIsLocal) {
|
||||
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
|
||||
|
||||
// Send sync data only for streaming books
|
||||
var syncData: JSObject = JSObject()
|
||||
syncData.put("timeListened", elapsed)
|
||||
syncData.put("currentTime", playerNotificationService.getCurrentTime() / 1000)
|
||||
syncData.put("streamId", listeningStreamId)
|
||||
syncData.put("audiobookId", listeningBookId)
|
||||
sendStreamSyncData(syncData) {
|
||||
Log.d(tag, "Stream sync done")
|
||||
}
|
||||
} else if (listeningStreamId == "download") {
|
||||
// TODO: Save downloaded audiobook progress & send to server if connected
|
||||
Log.d(tag, "ListeningTimer: Is listening download")
|
||||
|
||||
// Send sync data only for local books
|
||||
var syncData: JSObject = JSObject()
|
||||
var duration = playerNotificationService.getAudiobookDuration() / 1000
|
||||
var currentTime = playerNotificationService.getCurrentTime() / 1000
|
||||
syncData.put("totalDuration", duration)
|
||||
syncData.put("currentTime", currentTime)
|
||||
syncData.put("progress", if (duration > 0) (currentTime / duration) else 0)
|
||||
syncData.put("isRead", false)
|
||||
syncData.put("lastUpdate", System.currentTimeMillis())
|
||||
syncData.put("audiobookId", listeningBookId)
|
||||
sendLocalSyncData(syncData) {
|
||||
Log.d(tag, "Local sync done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalSyncData(payload:JSObject, cb: (() -> Unit)) {
|
||||
var serverUrl = playerNotificationService.getServerUrl()
|
||||
var token = playerNotificationService.getUserToken()
|
||||
|
||||
if (serverUrl == "" || token == "") {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "Sync Local $serverUrl | $token")
|
||||
var url = "$serverUrl/api/syncLocal"
|
||||
sendServerRequest(url, token, payload, cb)
|
||||
}
|
||||
|
||||
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
|
||||
var serverUrl = playerNotificationService.getServerUrl()
|
||||
var token = playerNotificationService.getUserToken()
|
||||
|
||||
if (serverUrl == "" || token == "") {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "Sync Stream $serverUrl | $token")
|
||||
var url = "$serverUrl/api/syncStream"
|
||||
sendServerRequest(url, token, payload, cb)
|
||||
}
|
||||
|
||||
fun sendServerRequest(url:String, token:String, payload:JSObject, cb: () -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.lang.Exception
|
||||
|
||||
class AudiobookStreamData {
|
||||
var id:String = "unset"
|
||||
var audiobookId:String = ""
|
||||
var token:String = ""
|
||||
var playlistUrl:String = ""
|
||||
var title:String = "No Title"
|
||||
var author:String = "Unknown"
|
||||
var series:String = ""
|
||||
var cover:String = ""
|
||||
var playWhenReady:Boolean = false
|
||||
var startTime:Long = 0
|
||||
var playbackSpeed:Float = 1f
|
||||
var duration:Long = 0
|
||||
var tracks:MutableList<String> = mutableListOf()
|
||||
|
||||
var isLocal:Boolean = false
|
||||
var contentUrl:String = ""
|
||||
|
||||
var hasPlayerLoaded:Boolean = false
|
||||
|
||||
var playlistUri:Uri = Uri.EMPTY
|
||||
var coverUri:Uri = Uri.EMPTY
|
||||
var contentUri:Uri = Uri.EMPTY // For Local only
|
||||
|
||||
constructor(jsondata:JSObject) {
|
||||
id = jsondata.getString("id", "unset").toString()
|
||||
audiobookId = jsondata.getString("audiobookId", "").toString()
|
||||
title = jsondata.getString("title", "No Title").toString()
|
||||
token = jsondata.getString("token", "").toString()
|
||||
author = jsondata.getString("author", "Unknown").toString()
|
||||
series = jsondata.getString("series", "").toString()
|
||||
cover = jsondata.getString("cover", "").toString()
|
||||
playlistUrl = jsondata.getString("playlistUrl", "").toString()
|
||||
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
|
||||
|
||||
if (jsondata.has("startTime")) {
|
||||
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("duration")) {
|
||||
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("playbackSpeed")) {
|
||||
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
|
||||
}
|
||||
|
||||
|
||||
// Local data
|
||||
isLocal = jsondata.getBoolean("isLocal", false) == true
|
||||
contentUrl = jsondata.getString("contentUrl", "").toString()
|
||||
|
||||
if (playlistUrl != "") {
|
||||
playlistUri = Uri.parse(playlistUrl)
|
||||
}
|
||||
if (cover != "" && cover != null) {
|
||||
coverUri = Uri.parse(cover)
|
||||
} else {
|
||||
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
cover = coverUri.toString()
|
||||
}
|
||||
|
||||
if (contentUrl != "") {
|
||||
contentUri = Uri.parse(contentUrl)
|
||||
}
|
||||
|
||||
// Tracks for cast
|
||||
try {
|
||||
var tracksTest = jsondata.getJSONArray("tracks")
|
||||
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
|
||||
for (i in 0 until tracksTest.length()) {
|
||||
var track = tracksTest.get(i)
|
||||
Log.d("AudiobookStreamData", "Extracting track $track")
|
||||
tracks.add(track as String)
|
||||
}
|
||||
} catch(e:Exception) {
|
||||
Log.d("AudiobookStreamData", "No tracks found $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCover() {
|
||||
coverUri = Uri.EMPTY
|
||||
cover = ""
|
||||
}
|
||||
|
||||
fun getMediaMetadataCompat():MediaMetadataCompat {
|
||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
|
||||
// if (cover != "") {
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
|
||||
// }
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMediaMetadata():MediaMetadata {
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
|
||||
// if (coverUri != Uri.EMPTY) {
|
||||
// metadataBuilder.setArtworkUri(coverUri)
|
||||
// }
|
||||
if (playlistUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(playlistUri)
|
||||
}
|
||||
if (contentUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
}
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMimeType():String {
|
||||
return if (isLocal) {
|
||||
MimeTypes.BASE_TYPE_AUDIO
|
||||
} else {
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaUri():Uri {
|
||||
return if (isLocal) {
|
||||
contentUri
|
||||
} else {
|
||||
Uri.parse("$playlistUrl?token=$token")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCastQueue():ArrayList<MediaItem> {
|
||||
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
|
||||
|
||||
for (i in 0 until tracks.size) {
|
||||
var track = tracks[i]
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
.setTrackNumber(i + 1)
|
||||
|
||||
if (coverUri != Uri.EMPTY) {
|
||||
metadataBuilder.setArtworkUri(coverUri)
|
||||
}
|
||||
|
||||
var mimeType = MimeTypes.BASE_TYPE_AUDIO
|
||||
|
||||
var mediaMetadata = metadataBuilder.build()
|
||||
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
mediaQueue.add(mediaItem)
|
||||
}
|
||||
|
||||
return mediaQueue
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Book {
|
||||
var title:String
|
||||
var subtitle:String
|
||||
var author:String
|
||||
var authorFL:String
|
||||
var narrator:String
|
||||
var series:String
|
||||
var volumeNumber:String
|
||||
var publisher:String
|
||||
var description:String
|
||||
var publishYear:String
|
||||
var language:String
|
||||
var cover:String
|
||||
var coverFullPath:String
|
||||
var genres:String
|
||||
var lastUpdate:Long
|
||||
|
||||
constructor(jsobj: JSObject) {
|
||||
title = jsobj.getString("title", "").toString()
|
||||
subtitle = jsobj.getString("subtitle", "").toString()
|
||||
author = jsobj.getString("author", "").toString()
|
||||
authorFL = jsobj.getString("authorFL", "").toString()
|
||||
narrator = jsobj.getString("narrator", "").toString()
|
||||
series = jsobj.getString("series", "").toString()
|
||||
volumeNumber = jsobj.getString("volumeNumber", "").toString()
|
||||
publisher = jsobj.getString("publisher", "").toString()
|
||||
description = jsobj.getString("description", "").toString()
|
||||
publishYear = jsobj.getString("publishYear", "").toString()
|
||||
language = jsobj.getString("language", "").toString()
|
||||
cover = jsobj.getString("cover", "").toString()
|
||||
coverFullPath = jsobj.getString("coverFullPath", "").toString()
|
||||
genres = jsobj.getString("genres", "").toString()
|
||||
lastUpdate = jsobj.getLong("lastUpdate")
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyRes
|
||||
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
audiobooksInProgress: List<Audiobook>,
|
||||
audiobookMetadata: List<MediaMetadataCompat>,
|
||||
downloadedMetadata: List<MediaMetadataCompat>
|
||||
) {
|
||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param context - context
|
||||
* @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))
|
||||
}
|
||||
|
||||
init {
|
||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||
|
||||
|
||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
}.build()
|
||||
|
||||
val allMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks")
|
||||
|
||||
var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString()
|
||||
Log.d("BrowseTree", "RESOURCE $resource")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource)
|
||||
}.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(context, R.drawable.exo_icon_downloaddone).toString())
|
||||
}.build()
|
||||
|
||||
// val localsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
// }.build()
|
||||
|
||||
if (audiobooksInProgress.isNotEmpty()) {
|
||||
rootList += continueReadingMetadata
|
||||
}
|
||||
rootList += allMetadata
|
||||
rootList += downloadsMetadata
|
||||
// rootList += localsMetadata
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
|
||||
audiobooksInProgress.forEach { audiobook ->
|
||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
||||
children += audiobook.toMediaMetadata()
|
||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
||||
}
|
||||
|
||||
audiobookMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[ALL_ROOT] = allChildren
|
||||
}
|
||||
|
||||
downloadedMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = allChildren
|
||||
}
|
||||
|
||||
// localAudio.forEach { local ->
|
||||
// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf()
|
||||
// localChildren += local.toMediaMetadata()
|
||||
// mediaIdToChildren[LOCAL_ROOT] = localChildren
|
||||
// }
|
||||
// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}")
|
||||
}
|
||||
|
||||
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
||||
}
|
||||
|
||||
const val AUTO_BROWSE_ROOT = "/"
|
||||
const val ALL_ROOT = "__ALL__"
|
||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
//const val LOCAL_ROOT = "__LOCAL__"
|
||||
@@ -11,8 +11,10 @@ import com.google.android.gms.cast.framework.media.CastMediaOptions
|
||||
class CastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
Log.d("CastOptionsProvider", "getCastOptions")
|
||||
var appId = "FD1F76C5"
|
||||
// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
|
||||
.setReceiverApplicationId(appId).setCastMediaOptions(
|
||||
CastMediaOptions.Builder()
|
||||
// We manage the media session and the notifications ourselves.
|
||||
.setMediaSessionEnabled(false)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class LocalMediaManager {
|
||||
private var ctx: Context
|
||||
val tag = "LocalAudioManager"
|
||||
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
data class LocalAudio(val uri: Uri,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val duration: Int,
|
||||
val size: Int,
|
||||
val coverUri: Uri?
|
||||
) {
|
||||
|
||||
fun toMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
|
||||
if (coverUri != null) {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
val localAudioFiles = mutableListOf<LocalAudio>()
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param context - context
|
||||
* @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))
|
||||
}
|
||||
|
||||
fun loadLocalAudio() {
|
||||
localAudioFiles.clear()
|
||||
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
|
||||
// TODO: No longer reading in local audio files - just use samples
|
||||
// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
// Log.e(tag, "Permission not granted to read from external storage")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// val collection =
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// MediaStore.Audio.Media.getContentUri(
|
||||
// MediaStore.VOLUME_EXTERNAL
|
||||
// )
|
||||
// } else {
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
// }
|
||||
//
|
||||
// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
|
||||
// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
|
||||
//
|
||||
// audioCursor?.use { cursor ->
|
||||
// // Cache column indices.
|
||||
// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
// val nameColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
// val durationColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
|
||||
//
|
||||
// while (cursor.moveToNext()) {
|
||||
// // Get values of columns for a given video.
|
||||
// val id = cursor.getLong(idColumn)
|
||||
// val name = cursor.getString(nameColumn)
|
||||
// val duration = cursor.getInt(durationColumn)
|
||||
// val size = cursor.getInt(sizeColumn)
|
||||
//
|
||||
// val contentUri: Uri = ContentUris.withAppendedId(
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
// id
|
||||
// )
|
||||
// Log.d(tag, "Found local audio file $name")
|
||||
// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
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.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
|
||||
@@ -17,47 +29,55 @@ class MainActivity : BridgeActivity() {
|
||||
private lateinit var mConnection : ServiceConnection
|
||||
|
||||
lateinit var pluginCallback : () -> Unit
|
||||
lateinit var downloaderCallback : (String, Long) -> Unit
|
||||
|
||||
val storageHelper = SimpleStorageHelper(this)
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
val broadcastReceiver = object: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
downloaderCallback("complete", thisdlid)
|
||||
}
|
||||
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
|
||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
downloaderCallback("clicked", thisdlid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val REQUEST_PERMISSIONS = 1
|
||||
var PERMISSIONS_ALL = arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// TODO: Optimize using strict mode logs
|
||||
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
||||
// .detectDiskReads()
|
||||
// .detectDiskWrites().detectAll()
|
||||
// .detectNetwork() // or .detectAll() for all detectable problems
|
||||
// .penaltyLog()
|
||||
// .build())
|
||||
// StrictMode.setVmPolicy(VmPolicy.Builder()
|
||||
// .detectLeakedSqlLiteObjects()
|
||||
// .detectLeakedClosableObjects()
|
||||
// .penaltyLog()
|
||||
// .build())
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Log.d(tag, "onCreate")
|
||||
registerPlugin(MyNativeAudio::class.java)
|
||||
registerPlugin(AudioDownloader::class.java)
|
||||
registerPlugin(StorageManager::class.java)
|
||||
|
||||
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
|
||||
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
|
||||
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
}
|
||||
registerReceiver(broadcastReceiver, filter)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
Log.d(tag, "onPostCreate MainActivity")
|
||||
|
||||
mConnection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
@@ -68,36 +88,31 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
Log.d(tag, "Service Connected $name")
|
||||
|
||||
|
||||
mBounded = true
|
||||
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||
foregroundService = mLocalBinder.getService()
|
||||
|
||||
// Let MyNativeAudio know foreground service is ready and setup event listener
|
||||
if (pluginCallback != null) {
|
||||
pluginCallback()
|
||||
}
|
||||
// Let NativeAudio know foreground service is ready and setup event listener
|
||||
pluginCallback()
|
||||
}
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
|
||||
Intent(this, PlayerNotificationService::class.java).also { intent ->
|
||||
Log.d(tag, "Binding PlayerNotificationService")
|
||||
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
mConnection?.let { unbindService(it) };
|
||||
mConnection.let { unbindService(it) };
|
||||
mBounded = false;
|
||||
}
|
||||
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
stopService(stopIntent)
|
||||
}
|
||||
|
||||
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
|
||||
downloaderCallback = cb
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
@@ -122,9 +137,4 @@ class MainActivity : BridgeActivity() {
|
||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
// override fun onUserInteraction() {
|
||||
// super.onUserInteraction()
|
||||
// Log.d(tag, "USER INTERACTION")
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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.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 MediaPlayerWidget : AppWidgetProvider() {
|
||||
val tag = "MediaPlayerWidget"
|
||||
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, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||
|
||||
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.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 onDisabled(context: Context) {
|
||||
// Enter relevant functionality for when the last widget is disabled
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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() ?: 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) R.drawable.ic_media_pause_dark else 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,251 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.capacitorjs.plugins.app.AppPlugin
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "MyNativeAudio")
|
||||
class MyNativeAudio : Plugin() {
|
||||
private val tag = "MyNativeAudio"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
lateinit var playerNotificationService: PlayerNotificationService
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
var foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
|
||||
playerNotificationService.setBridge(bridge)
|
||||
|
||||
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
|
||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
||||
emit("onPlayingUpdate", isPlaying)
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: JSObject) {
|
||||
notifyListeners("onMetadata", metadata)
|
||||
}
|
||||
|
||||
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("playWhenReady", playWhenReady)
|
||||
notifyListeners("onPrepareMedia", jsobj)
|
||||
}
|
||||
|
||||
override fun onSleepTimerEnded(currentPosition: Long) {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
|
||||
emit("onSleepTimerSet", sleepTimeRemaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
}
|
||||
|
||||
fun emit(evtName: String, value: Any) {
|
||||
var ret:JSObject = JSObject()
|
||||
ret.put("value", value)
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun initPlayer(call: PluginCall) {
|
||||
if (!PlayerNotificationService.isStarted) {
|
||||
Log.w(tag, "Starting foreground service --")
|
||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||
ContextCompat.startForegroundService(mainActivity, intent)
|
||||
}
|
||||
}
|
||||
var jsobj = JSObject()
|
||||
|
||||
var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data)
|
||||
if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") {
|
||||
Log.e(tag, "Invalid URL for init audio player")
|
||||
|
||||
jsobj.put("success", false)
|
||||
return call.resolve(jsobj)
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.initPlayer(audiobookStreamData)
|
||||
jsobj.put("success", true)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
var bufferedTime = playerNotificationService.getBufferedTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", currentTime)
|
||||
ret.put("bufferedTime", bufferedTime)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getStreamSyncData(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var isPlaying = playerNotificationService.getPlayStatus()
|
||||
var lastPauseTime = playerNotificationService.getTheLastPauseTime()
|
||||
Log.d(tag, "Get Last Pause Time $lastPauseTime")
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
//if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime()
|
||||
var id = playerNotificationService.getCurrentAudiobookId()
|
||||
Log.d(tag, "Get Current id $id")
|
||||
var duration = playerNotificationService.getDuration()
|
||||
Log.d(tag, "Get duration $duration")
|
||||
val ret = JSObject()
|
||||
ret.put("lastPauseTime", lastPauseTime)
|
||||
ret.put("currentTime", currentTime)
|
||||
ret.put("isPlaying", isPlaying)
|
||||
ret.put("id", id)
|
||||
ret.put("duration", duration)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun pausePlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.pause()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun playPlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.play()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekPlayer(call: PluginCall) {
|
||||
var time:Long = call.getString("timeMs", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekPlayer(time)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekForward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekForward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekBackward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekBackward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPlaybackSpeed(call: PluginCall) {
|
||||
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.setPlaybackSpeed(playbackSpeed)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun terminateStream(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.terminateStream()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setSleepTimer(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "360000")!!.toLong()
|
||||
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSleepTimerTime(call: PluginCall) {
|
||||
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", time)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun increaseSleepTime(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun decreaseSleepTime(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun requestSession(call: PluginCall) {
|
||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||
|
||||
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
|
||||
override fun onError(errorCode: Int) {
|
||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
|
||||
}
|
||||
|
||||
override fun onJoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
||||
|
||||
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerExtensionTime:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||
}
|
||||
// For chapter end timer
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = false
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
var currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
sleepTimerExtensionTime = time
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
|
||||
sleepTimerRunning = true
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimeSecondsRemaining)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
||||
// Start fading out audio
|
||||
var volume = sleepTimeSecondsRemaining / 30F
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long? {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
clearSleepTimer()
|
||||
playerNotificationService.listener?.onSleepTimerSet(0)
|
||||
}
|
||||
|
||||
private fun extendSleepTime() {
|
||||
if (!sleepTimerRunning) return
|
||||
setVolume(1F)
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength += sleepTimerExtensionTime
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
} else {
|
||||
sleepTimerEndTime += sleepTimerExtensionTime
|
||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
||||
}
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
}
|
||||
|
||||
fun checkShouldExtendSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
||||
{
|
||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
||||
setSleepTimer(newSleepTime, false)
|
||||
play()
|
||||
return
|
||||
}
|
||||
// Only extend if within 30 seconds of finishing
|
||||
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
if (sleepTimeRemaining <= 30) extendSleepTime()
|
||||
}
|
||||
|
||||
fun handleShake() {
|
||||
Log.d(tag, "HANDLE SHAKE HERE")
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
||||
}
|
||||
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength += time
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
} else {
|
||||
var newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength -= time
|
||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||
} else {
|
||||
var newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.callback.FolderPickerCallback
|
||||
import com.anggrayudi.storage.callback.StorageAccessCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "StorageManager")
|
||||
class StorageManager : Plugin() {
|
||||
private val TAG = "StorageManager"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
|
||||
data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("size", this.size)
|
||||
obj.put("type", this.type)
|
||||
obj.put("isAudio", this.isAudio)
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List<MediaFile>) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("files", this.mediaFiles.map { it.toJSObject() })
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun selectFolder(call: PluginCall) {
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
|
||||
var absolutePath = folder.getAbsolutePath(activity)
|
||||
var storageId = folder.getStorageId(activity)
|
||||
var storageType = folder.getStorageType(activity)
|
||||
var simplePath = folder.getSimplePath(activity)
|
||||
var basePath = folder.getBasePath(activity)
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("uri", folder.uri)
|
||||
jsobj.put("absolutePath", absolutePath)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", storageType)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("basePath", basePath)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
||||
Log.e(TAG, "STORAGE ACCESS DENIED")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("error", "Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
mainActivity.storage.openFolderPicker(6)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@PluginMethod
|
||||
fun requestStoragePermission(call: PluginCall) {
|
||||
Log.d(TAG, "Request Storage Permissions")
|
||||
mainActivity.storageHelper.requestStorageAccess()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkStoragePermission(call: PluginCall) {
|
||||
var res = false
|
||||
|
||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
res = SimpleStorage.hasStoragePermission(context)
|
||||
Log.d(TAG, "Check Storage Access $res")
|
||||
} else {
|
||||
Log.d(TAG, "Has permission on Android 10 or up")
|
||||
res = true
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("value", res)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkFolderPermissions(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
|
||||
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("value", hasAccess)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun searchFolder(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Searching folder $folderUrl")
|
||||
|
||||
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", JSArray())
|
||||
jsobj.put("files", JSArray())
|
||||
call.resolve(jsobj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
|
||||
|
||||
var mediaFolders = mutableListOf<MediaFolder>()
|
||||
var foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
|
||||
foldersFound.forEach {
|
||||
Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}")
|
||||
var folderName = it.name ?: ""
|
||||
var mediaFiles = mutableListOf<MediaFile>()
|
||||
|
||||
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
filesInFolder.forEach { it2 ->
|
||||
var mimeType = it2?.mimeType ?: ""
|
||||
var filename = it2?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in folder $folderName")
|
||||
var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio)
|
||||
mediaFiles.add(imageFile)
|
||||
}
|
||||
if (mediaFiles.size > 0) {
|
||||
mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles))
|
||||
}
|
||||
}
|
||||
|
||||
// Files in root dir
|
||||
var rootMediaFiles = mutableListOf<MediaFile>()
|
||||
var mediaFilesFound:List<DocumentFile> = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
mediaFilesFound.forEach {
|
||||
Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}")
|
||||
var mimeType = it?.mimeType ?: ""
|
||||
var filename = it?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in root folder")
|
||||
var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio)
|
||||
rootMediaFiles.add(imageFile)
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", mediaFolders.map{ it.toJSObject() })
|
||||
jsobj.put("files", rootMediaFiles.map{ it.toJSObject() })
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
|
||||
@PluginMethod
|
||||
fun delete(call: PluginCall) {
|
||||
var url = call.data.getString("url", "").toString()
|
||||
var coverUrl = call.data.getString("coverUrl", "").toString()
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
|
||||
if (folderUrl != "") {
|
||||
Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl")
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
var success = folder?.deleteRecursively(context)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("success", success)
|
||||
call.resolve()
|
||||
} else {
|
||||
// Older audiobooks did not store a folder url, use cover and audiobook url
|
||||
var abExists = checkUriExists(Uri.parse(url))
|
||||
if (abExists) {
|
||||
var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url))
|
||||
abfile?.delete()
|
||||
}
|
||||
|
||||
var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
if (coverExists) {
|
||||
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
coverfile?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkUriExists(uri: Uri?): Boolean {
|
||||
if (uri == null) return false
|
||||
val resolver = context.contentResolver
|
||||
var cursor: Cursor? = null
|
||||
return try {
|
||||
cursor = resolver.query(uri, null, null, null, null)
|
||||
//cursor null: content Uri was invalid or some other error occurred
|
||||
//cursor.moveToFirst() false: Uri was ok but no entry found.
|
||||
(cursor != null && cursor.moveToFirst())
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
} finally {
|
||||
try {
|
||||
cursor?.close()
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeStream(
|
||||
val index:Int,
|
||||
val codec_name:String,
|
||||
val codec_long_name:String,
|
||||
val channels:Int,
|
||||
val channel_layout:String,
|
||||
val duration:Double,
|
||||
val bit_rate:Double
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapterTags(
|
||||
val title:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapter(
|
||||
val id:Int,
|
||||
val start:Long,
|
||||
val end:Long,
|
||||
val tags:AudioProbeChapterTags?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
val startS = start / 1000.0
|
||||
val endS = end / 1000.0
|
||||
val title = tags?.title ?: "Chapter $id"
|
||||
return BookChapter(id, startS, endS, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormatTags(
|
||||
val artist:String?,
|
||||
val album:String?,
|
||||
val comment:String?,
|
||||
val date:String?,
|
||||
val genre:String?,
|
||||
val title:String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormat(
|
||||
val filename:String,
|
||||
val format_name:String,
|
||||
val duration:Double,
|
||||
val size:Long,
|
||||
val bit_rate:Double,
|
||||
val tags:AudioProbeFormatTags?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class AudioProbeResult (
|
||||
val streams:MutableList<AudioProbeStream>,
|
||||
val chapters:MutableList<AudioProbeChapter>,
|
||||
val format:AudioProbeFormat) {
|
||||
|
||||
val duration get() = format.duration
|
||||
val size get() = format.size
|
||||
val title get() = format.tags?.title ?: format.filename.split("/").last()
|
||||
val artist get() = format.tags?.artist ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapters(): List<BookChapter> {
|
||||
if (chapters.isEmpty()) return mutableListOf()
|
||||
return chapters.map { it.getBookChapter() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
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.*
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(Book::class),
|
||||
JsonSubTypes.Type(Podcast::class)
|
||||
)
|
||||
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||
@JsonIgnore
|
||||
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
|
||||
@JsonIgnore
|
||||
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
|
||||
@JsonIgnore
|
||||
open fun addAudioTrack(audioTrack:AudioTrack) { }
|
||||
@JsonIgnore
|
||||
open fun removeAudioTrack(localFileId:String) { }
|
||||
@JsonIgnore
|
||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
||||
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class Podcast(
|
||||
metadata:PodcastMetadata,
|
||||
coverPath:String?,
|
||||
var tags:MutableList<String>,
|
||||
var episodes:MutableList<PodcastEpisode>?,
|
||||
var autoDownloadEpisodes:Boolean,
|
||||
var numEpisodes:Int?
|
||||
) : MediaType(metadata, coverPath) {
|
||||
@JsonIgnore
|
||||
override fun getAudioTracks():List<AudioTrack> {
|
||||
val tracks = episodes?.map { it.audioTrack }
|
||||
return tracks?.filterNotNull() ?: mutableListOf()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
// Remove episodes no longer there in tracks
|
||||
episodes = episodes?.filter { ep ->
|
||||
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
|
||||
// Add new episodes
|
||||
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)
|
||||
episodes?.add(newEpisode)
|
||||
}
|
||||
}
|
||||
|
||||
var index = 1
|
||||
episodes?.forEach {
|
||||
it.index = index
|
||||
index++
|
||||
}
|
||||
}
|
||||
@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)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
episodes?.forEach {
|
||||
it.index = index
|
||||
index++
|
||||
}
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun removeAudioTrack(localFileId:String) {
|
||||
episodes?.removeIf { it.audioTrack?.localFileId == localFileId }
|
||||
|
||||
var index = 1
|
||||
episodes?.forEach {
|
||||
it.index = index
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Podcast {
|
||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 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)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
episodes?.forEach {
|
||||
it.index = index
|
||||
index++
|
||||
}
|
||||
return newEpisode
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
|
||||
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
|
||||
val podcastEpisode = sortedEpisodes?.find { episode ->
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
|
||||
progress == null || !progress.isFinished
|
||||
}
|
||||
return podcastEpisode
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class Book(
|
||||
metadata:BookMetadata,
|
||||
coverPath:String?,
|
||||
var tags:List<String>,
|
||||
var audioFiles:List<AudioFile>?,
|
||||
var chapters:List<BookChapter>?,
|
||||
var tracks:MutableList<AudioTrack>?,
|
||||
var size:Long?,
|
||||
var duration:Double?,
|
||||
var numTracks:Int?
|
||||
) : MediaType(metadata, coverPath) {
|
||||
@JsonIgnore
|
||||
override fun getAudioTracks():List<AudioTrack> {
|
||||
return tracks ?: mutableListOf()
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
tracks = audioTracks
|
||||
tracks?.sortBy { it.index }
|
||||
|
||||
var totalDuration = 0.0
|
||||
var currentStartOffset = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
it.startOffset = currentStartOffset
|
||||
currentStartOffset += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
tracks?.add(audioTrack)
|
||||
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun removeAudioTrack(localFileId:String) {
|
||||
tracks?.removeIf { it.localFileId == localFileId }
|
||||
|
||||
tracks?.sortBy { it.index }
|
||||
|
||||
var index = 1
|
||||
var startOffset = 0.0
|
||||
var totalDuration = 0.0
|
||||
tracks?.forEach {
|
||||
it.index = index
|
||||
it.startOffset = startOffset
|
||||
totalDuration += it.duration
|
||||
|
||||
index++
|
||||
startOffset += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(BookMetadata::class),
|
||||
JsonSubTypes.Type(PodcastMetadata::class)
|
||||
)
|
||||
open class MediaTypeMetadata(var title:String) {
|
||||
@JsonIgnore
|
||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class BookMetadata(
|
||||
title:String,
|
||||
var subtitle:String?,
|
||||
var authors:MutableList<Author>?,
|
||||
var narrators:MutableList<String>?,
|
||||
var genres:MutableList<String>,
|
||||
var publishedYear:String?,
|
||||
var publishedDate:String?,
|
||||
var publisher:String?,
|
||||
var description:String?,
|
||||
var isbn:String?,
|
||||
var asin:String?,
|
||||
var language:String?,
|
||||
var explicit:Boolean,
|
||||
// In toJSONExpanded
|
||||
var authorName:String?,
|
||||
var authorNameLF:String?,
|
||||
var narratorName:String?,
|
||||
var seriesName:String?
|
||||
) : MediaTypeMetadata(title) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PodcastMetadata(
|
||||
title:String,
|
||||
var author:String?,
|
||||
var feedUrl:String?,
|
||||
var genres:MutableList<String>
|
||||
) : MediaTypeMetadata(title) {
|
||||
@JsonIgnore
|
||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Author(
|
||||
var id:String,
|
||||
var name:String,
|
||||
var coverPath:String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PodcastEpisode(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var episode:String?,
|
||||
var episodeType:String?,
|
||||
var title:String?,
|
||||
var subtitle:String?,
|
||||
var description:String?,
|
||||
var pubDate:String?,
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
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 {
|
||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
|
||||
if (localEpisodeId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
val mediaId = localEpisodeId ?: id
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(libraryItemDescription.title)
|
||||
.setExtras(extras)
|
||||
|
||||
libraryItemDescription.iconBitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryFile(
|
||||
var ino:String,
|
||||
var metadata:FileMetadata
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class FileMetadata(
|
||||
var filename:String,
|
||||
var ext:String,
|
||||
var path:String,
|
||||
var relPath:String,
|
||||
var size:Long?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioFile(
|
||||
var index:Int,
|
||||
var ino:String,
|
||||
var metadata:FileMetadata
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Library(
|
||||
var id:String,
|
||||
var name:String,
|
||||
var folders:MutableList<Folder>,
|
||||
var icon:String,
|
||||
var mediaType:String
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class Folder(
|
||||
var id:String,
|
||||
var fullPath:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class BookChapter(
|
||||
var id:Int,
|
||||
var start:Double,
|
||||
var end:Double,
|
||||
var title:String?
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val startMs get() = (start * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endMs get() = (end * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(MediaProgress::class),
|
||||
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||
)
|
||||
open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
|
||||
open val mediaItemId get() = ""
|
||||
}
|
||||
|
||||
// Helper class
|
||||
data class LibraryItemWithEpisode(
|
||||
var libraryItemWrapper:LibraryItemWrapper,
|
||||
var episode:PodcastEpisode
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
}
|
||||
|
||||
enum class HapticFeedbackSetting {
|
||||
OFF, LIGHT, MEDIUM, HEAVY
|
||||
}
|
||||
|
||||
enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFile(
|
||||
var id:String,
|
||||
var filename:String?,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var mimeType:String?,
|
||||
var size:Long
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun isAudioFile():Boolean {
|
||||
if (mimeType == "application/octet-stream") return true
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFolder(
|
||||
var id:String,
|
||||
var name:String,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var storageType:String,
|
||||
var mediaType:String
|
||||
)
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(LibraryItem::class),
|
||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PlayItemRequestPayload(
|
||||
var mediaPlayer:String,
|
||||
var forceDirectPlay:Boolean,
|
||||
var forceTranscode:Boolean,
|
||||
var deviceInfo:DeviceInfo
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||
else {
|
||||
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
|
||||
1.6f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
return lastServerConnectionConfigId?.let { lsccid ->
|
||||
return serverConnectionConfigs.find { it.id == lsccid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
data class FolderScanResult(
|
||||
var itemsAdded:Int,
|
||||
var itemsUpdated:Int,
|
||||
var itemsRemoved:Int,
|
||||
var itemsUpToDate:Int,
|
||||
val localFolder:LocalFolder,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
||||
data class LocalLibraryItemScanResult(
|
||||
val updated:Boolean,
|
||||
val localLibraryItem:LocalLibraryItem,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||
*/
|
||||
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.json.JSONObject
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ItemInProgress(
|
||||
val libraryItemWrapper: LibraryItemWrapper,
|
||||
val episode: PodcastEpisode?,
|
||||
val progressLastUpdate: Long,
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||
|
||||
var episode:PodcastEpisode? = null
|
||||
if (serverItem.has("recentEpisode")) {
|
||||
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||
}
|
||||
|
||||
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LibraryCategory(
|
||||
var id:String,
|
||||
var label:String,
|
||||
var type:String,
|
||||
var entities:List<LibraryItemWrapper>,
|
||||
var isLocal:Boolean
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
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 LibraryItem(
|
||||
id:String,
|
||||
var ino:String,
|
||||
var libraryId:String,
|
||||
var folderId:String,
|
||||
var path:String,
|
||||
var relPath:String,
|
||||
var mtimeMs:Long,
|
||||
var ctimeMs:Long,
|
||||
var birthtimeMs:Long,
|
||||
var addedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var lastScan:Long?,
|
||||
var scanVersion:String?,
|
||||
var isMissing:Boolean,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
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
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (media.coverPath == null) {
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
val mediaId = localLibraryItemId ?: id
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
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.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LocalLibraryItem(
|
||||
id:String,
|
||||
var folderId:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var contentUrl:String,
|
||||
var isInvalid:Boolean,
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?,
|
||||
var isLocal:Boolean,
|
||||
// If local library item is linked to a server item
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var libraryItemId:String?
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@get:JsonIgnore
|
||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||
@get:JsonIgnore
|
||||
val isPodcast get() = mediaType == "podcast"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
var total = 0.0
|
||||
val audioTracks = media.getAudioTracks()
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||
localFiles = _localFiles
|
||||
media.setAudioTracks(audioTracks)
|
||||
|
||||
if (coverContentUrl != null) {
|
||||
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
||||
// Cover was removed
|
||||
coverContentUrl = null
|
||||
coverAbsolutePath = null
|
||||
media.coverPath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
val sessionId = "play_local_${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
|
||||
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
val 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 }
|
||||
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(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun removeLocalFile(localFileId:String) {
|
||||
localFiles.removeIf { it.id == localFileId }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||
)
|
||||
} else {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||
)
|
||||
extras.putDouble(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||
)
|
||||
}
|
||||
} else if (mediaType != "podcast") {
|
||||
extras.putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(bitmap)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
/*
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaItem(
|
||||
var id:String,
|
||||
var name: String,
|
||||
var mediaType:String,
|
||||
var folderId:String,
|
||||
var contentUrl:String,
|
||||
var simplePath: String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
) {
|
||||
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalSize():Long {
|
||||
var total = 0L
|
||||
localFiles.forEach { total += it.size }
|
||||
return total
|
||||
}
|
||||
|
||||
@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)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf())
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getAudiobookChapters():List<BookChapter> {
|
||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
||||
}
|
||||
// Multi-track make chapters from tracks
|
||||
return audioTracks.map { it.getBookChapter() }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getLocalLibraryItem():LocalLibraryItem {
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class LocalMediaProgress(
|
||||
var id:String,
|
||||
var localLibraryItemId:String,
|
||||
var localEpisodeId:String?,
|
||||
var duration:Double,
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?,
|
||||
// For local lib items from server to support server sync
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?
|
||||
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||
@get:JsonIgnore
|
||||
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||
@get:JsonIgnore
|
||||
override val mediaItemId get() = if (libraryItemId != null) {
|
||||
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
} else {
|
||||
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) {
|
||||
if (isFinished != finished) { // If finished changed then set progress
|
||||
progress = if (finished) 1.0 else 0.0
|
||||
}
|
||||
|
||||
isFinished = finished
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
||||
currentTime = playbackSession.currentTime
|
||||
progress = playbackSession.progress
|
||||
lastUpdate = playbackSession.updatedAt
|
||||
isFinished = playbackSession.progress >= 0.99
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
startedAt = serverMediaProgress.startedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemEvent(
|
||||
var name:String, // e.g. Play/Pause/Stop/Seek/Save
|
||||
var type:String, // Playback/Info
|
||||
var description:String?,
|
||||
var currentTime:Number?, // Seconds
|
||||
var serverSyncAttempted:Boolean?,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?,
|
||||
var timestamp: Long
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemHistory(
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal:Boolean,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var createdAt: Long,
|
||||
var events:MutableList<MediaItemEvent>,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaProgress(
|
||||
var id:String,
|
||||
var libraryItemId:String,
|
||||
var episodeId:String?,
|
||||
var duration:Double, // seconds
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||
|
||||
@get:JsonIgnore
|
||||
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
enum class PlayerState {
|
||||
IDLE, BUFFERING, READY, ENDED
|
||||
}
|
||||
|
||||
data class PlaybackMetadata(
|
||||
val duration:Double,
|
||||
val currentTime:Double,
|
||||
val playerState:PlayerState
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import com.audiobookshelf.app.player.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
var id:String,
|
||||
var userId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath:String?,
|
||||
var duration:Double,
|
||||
var playMethod:Int,
|
||||
var startedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var timeListening:Long,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var currentTime:Double,
|
||||
var libraryItem:LibraryItem?,
|
||||
var localLibraryItem:LocalLibraryItem?,
|
||||
var localEpisodeId:String?,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var mediaPlayer:String?
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
@get:JsonIgnore
|
||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
@get:JsonIgnore
|
||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
||||
@get:JsonIgnore
|
||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
@get:JsonIgnore
|
||||
val progress get() = currentTime / getTotalDuration()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
@get:JsonIgnore
|
||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return audioTracks.size - 1
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackIndex():Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs < track.startOffsetMs) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return audioTracks.size - 1
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackEndTime():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackEndTime():Long {
|
||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
val time = currentTime - currentTrack.startOffset
|
||||
return (time * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
if (index < 0 || index >= audioTracks.size) return 0L
|
||||
val currentTrack = audioTracks[index]
|
||||
return (currentTrack.startOffset * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalDuration():Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: 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}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems():List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||
val mediaUri = this.getContentUri(audioTrack)
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
mediaItems.add(mediaItem)
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
||||
|
||||
coverPath?.let {
|
||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
||||
}
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||
|
||||
val mediaUri = getContentUri(audioTrack)
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}.build()
|
||||
|
||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
||||
setPlaybackDuration(audioTrack.duration)
|
||||
}.build()
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun syncData(syncData: MediaProgressSyncData) {
|
||||
timeListening += syncData.timeListened
|
||||
updatedAt = System.currentTimeMillis()
|
||||
currentTime = syncData.currentTime
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
}
|
||||
|
||||
object DeviceManager {
|
||||
const val tag = "DeviceManager"
|
||||
|
||||
val dbManager: DbManager = DbManager()
|
||||
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||
|
||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||
val token get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer get() = serverConnectionConfig != null
|
||||
|
||||
var widgetUpdater:WidgetEventEmitter? = null
|
||||
|
||||
init {
|
||||
Log.d(tag, "Device Manager Singleton invoked")
|
||||
|
||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||
}
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
enum class ItemScanResult {
|
||||
ADDED, REMOVED, UPDATED, UPTODATE
|
||||
}
|
||||
|
||||
// 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(true, 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) }
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, 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, filesInFolder:List<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 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
|
||||
} else {
|
||||
return ItemScanResult.UPTODATE
|
||||
}
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
var itemFolderBasePath = ""
|
||||
var itemFolderAbsolutePath = ""
|
||||
foldersFound.forEach {
|
||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||
itemFolderId = it.id
|
||||
itemFolderUrl = it.uri.toString()
|
||||
itemFolderBasePath = it.getBasePath(ctx)
|
||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return cb(null)
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||
|
||||
// Add podcast episodes to library
|
||||
itemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks 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 = false,
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
|
||||
companion object {
|
||||
private var isDbInitialized = false
|
||||
|
||||
fun initialize(ctx: Context) {
|
||||
if (isDbInitialized) return
|
||||
Paper.init(ctx)
|
||||
isDbInitialized = true
|
||||
Log.i("DbManager", "Initialized Paper db")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
}
|
||||
}
|
||||
return localLibraryItems
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
return localLibraryItems.filter {
|
||||
it.folderId == folderId
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
return if (localLibraryItem != null) {
|
||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
||||
localLibraryItems.map {
|
||||
Paper.book("localLibraryItems").write(it.id, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
||||
}
|
||||
|
||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
}
|
||||
|
||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||
return Paper.book("localFolders").read(folderId)
|
||||
}
|
||||
|
||||
fun getAllLocalFolders():List<LocalFolder> {
|
||||
val localFolders:MutableList<LocalFolder> = mutableListOf()
|
||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
||||
localFolders.add(it)
|
||||
}
|
||||
}
|
||||
return localFolders
|
||||
}
|
||||
|
||||
fun removeLocalFolder(folderId:String) {
|
||||
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||
localLibraryItems.forEach {
|
||||
Paper.book("localLibraryItems").delete(it.id)
|
||||
}
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
fun saveDownloadItem(downloadItem: DownloadItem) {
|
||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
fun removeDownloadItem(downloadItemId:String) {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<DownloadItem> {
|
||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||
downloadItems.add(it)
|
||||
}
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||
}
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||
}
|
||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||
mediaProgress.add(it)
|
||||
}
|
||||
}
|
||||
return mediaProgress
|
||||
}
|
||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||
}
|
||||
|
||||
fun removeAllLocalMediaProgress() {
|
||||
Paper.book("localMediaProgress").destroy()
|
||||
}
|
||||
|
||||
// Make sure all local file ids still exist
|
||||
fun cleanLocalLibraryItems() {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
var hasUpdates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as MutableList<LocalFile>
|
||||
|
||||
// Check audio tracks and episodes
|
||||
if (lli.isPodcast) {
|
||||
val podcast = lli.media as Podcast
|
||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
} else {
|
||||
val book = lli.media as Book
|
||||
book.tracks = book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as MutableList<AudioTrack>
|
||||
}
|
||||
|
||||
// Check cover still there
|
||||
lli.coverAbsolutePath?.let {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||
lli.coverAbsolutePath = null
|
||||
lli.coverContentUrl = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any local media progress where the local media item is not found
|
||||
fun cleanLocalMediaProgress() {
|
||||
val localMediaProgress = getAllLocalMediaProgress()
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
localMediaProgress.forEach {
|
||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||
if (!it.id.startsWith("local")) {
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
||||
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI.isPodcast) {
|
||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else {
|
||||
val podcast = matchingLLI.media as Podcast
|
||||
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||
if (matchingLEp == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
||||
import com.anggrayudi.storage.file.MimeType
|
||||
import com.anggrayudi.storage.file.getAbsolutePath
|
||||
import com.anggrayudi.storage.file.moveFileTo
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
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 = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
Successful,
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem:DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
}
|
||||
|
||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
||||
|
||||
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 (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
||||
}
|
||||
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
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)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
|
||||
delay(500)
|
||||
|
||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Finished watching downloads")
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
|
||||
return when (downloadStatus) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.progress = 1
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.Successful
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
|
||||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
downloadItemPart.progress = percentProgress
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.InProgress
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
return DownloadCheckStatus.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// fAILED
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
|
||||
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?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||
}
|
||||
// For chapter end timer
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
||||
// Set volume to 1 in case setting was enabled while fading
|
||||
setVolume(1f)
|
||||
} else {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
isAutoSleepTimer = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
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, false)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
val vibratorManager =
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibrator = vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
|
||||
vibrator.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
||||
it.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
it.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the chapter end time for use in End of Chapter timers
|
||||
// if less than 2s remain in chapter then use the next chapter
|
||||
private fun getChapterEndTime():Long? {
|
||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||
if (currentChapterEndTimeMs == null) {
|
||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||
return null
|
||||
}
|
||||
|
||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||
return if (timeLeftInChapter < 2000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
||||
null
|
||||
} else {
|
||||
nextChapterEndTimeMs
|
||||
}
|
||||
} else {
|
||||
currentChapterEndTimeMs
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkShouldResetSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
||||
{
|
||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleShake() {
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
}
|
||||
checkShouldResetSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength += time
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength -= time
|
||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun checkAutoSleepTimer() {
|
||||
if (sleepTimerRunning) { // Sleep timer already running
|
||||
return
|
||||
}
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
|
||||
|
||||
val startCalendar = Calendar.getInstance()
|
||||
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
|
||||
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
|
||||
val endCalendar = Calendar.getInstance()
|
||||
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.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
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")
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
val chapterEndTimeMs = this.getChapterEndTime()
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
|
||||
object MediaEventManager {
|
||||
const val tag = "MediaEventManager"
|
||||
|
||||
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
|
||||
|
||||
fun playEvent(playbackSession: PlaybackSession) {
|
||||
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Play", playbackSession, null)
|
||||
}
|
||||
|
||||
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Pause", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Stop", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Save", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Finished", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
||||
addSyncEvent("Sync", mediaProgress, description)
|
||||
}
|
||||
|
||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||
if (mediaItemHistory == null) {
|
||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||
}
|
||||
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
||||
return MediaItemHistory(
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.getcapacitor.JSObject
|
||||
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
|
||||
|
||||
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 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>()
|
||||
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
}
|
||||
|
||||
fun getSavedPlaybackRate():Float {
|
||||
if (userSettingsPlaybackRate != null) {
|
||||
return userSettingsPlaybackRate ?: 1f
|
||||
}
|
||||
|
||||
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
if (sharedPrefs != null) {
|
||||
val userSettingsPref = sharedPrefs.getString("userSettings", null)
|
||||
if (userSettingsPref != null) {
|
||||
try {
|
||||
val userSettings = JSObject(userSettingsPref)
|
||||
if (userSettings.has("playbackRate")) {
|
||||
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
|
||||
return userSettingsPlaybackRate ?: 1f
|
||||
}
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1f
|
||||
}
|
||||
|
||||
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() {
|
||||
// When opening android auto need to check if still connected to server
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
selectedLibraryItems = mutableListOf()
|
||||
selectedLibraryId = ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||
if (serverItemsInProgress.isNotEmpty()) {
|
||||
cb(serverItemsInProgress)
|
||||
} else {
|
||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||
serverItemsInProgress = itemsInProgress
|
||||
cb(serverItemsInProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||
cb(selectedLibraryItems)
|
||||
} 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)
|
||||
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
|
||||
serverLibraryItems.add(libraryItem)
|
||||
}
|
||||
}
|
||||
cb(libraryItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||
if (libraryItemId.startsWith("local")) {
|
||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||
} else {
|
||||
Log.d(tag, "loadLibraryItem: $libraryItemId")
|
||||
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||
|
||||
libraryItemWrapper?.let {
|
||||
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
podcast.episodes?.forEach { podcastEpisode ->
|
||||
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
|
||||
}
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id }
|
||||
podcastEpisode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||
if (serverLibraries.isNotEmpty()) {
|
||||
cb(serverLibraries)
|
||||
} else {
|
||||
apiHandler.getLibraries {
|
||||
serverLibraries = it
|
||||
cb(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
|
||||
var successfulPing = false
|
||||
suspendCoroutine { cont ->
|
||||
apiHandler.pingServer(config) {
|
||||
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
||||
successfulPing = it
|
||||
cont.resume(it)
|
||||
}
|
||||
}
|
||||
return successfulPing
|
||||
}
|
||||
|
||||
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
suspendCoroutine { cont ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
mediaProgress = it
|
||||
}
|
||||
cont.resume(mediaProgress)
|
||||
}
|
||||
}
|
||||
return mediaProgress
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
|
||||
coroutineScope {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
|
||||
hasValidConn = true
|
||||
lookupMediaProgress = false
|
||||
} else {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
}
|
||||
|
||||
if (!hasValidConn) {
|
||||
// First check if the current selected config is pingable
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
hasValidConn = checkServerConnection(it)
|
||||
Log.d(
|
||||
tag,
|
||||
"checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidConn) {
|
||||
// Loop through available configs and check if can connect
|
||||
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
|
||||
val result = checkServerConnection(config)
|
||||
|
||||
if (result) {
|
||||
hasValidConn = true
|
||||
DeviceManager.serverConnectionConfig = config
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidConn) {
|
||||
serverConfigLastPing = System.currentTimeMillis()
|
||||
|
||||
if (lookupMediaProgress) {
|
||||
Log.d(tag, "Has valid conn now get user media progress")
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
serverUserMediaProgress = authorize(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cb(hasValidConn)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun loadServerUserMediaProgress(cb: () -> Unit) {
|
||||
Log.d(tag, "Loading server media progress")
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
return cb()
|
||||
}
|
||||
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
serverUserMediaProgress = it
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFirstItem() : LibraryItemWrapper? {
|
||||
if (serverLibraryItems.isNotEmpty()) {
|
||||
return serverLibraryItems[0]
|
||||
} else {
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
return if (localBooks.isNotEmpty()) return localBooks[0] else null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
||||
return if (id.startsWith("local")) {
|
||||
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||
} else {
|
||||
podcastEpisodeLibraryItemMap[id]
|
||||
}
|
||||
}
|
||||
|
||||
fun getById(id:String) : LibraryItemWrapper? {
|
||||
return if (id.startsWith("local")) {
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
} else {
|
||||
serverLibraryItems.find { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
fun getFromSearch(query:String?) : LibraryItemWrapper? {
|
||||
if (query.isNullOrEmpty()) return getFirstItem()
|
||||
return serverLibraryItems.find {
|
||||
it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
|
||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
if (libraryItemWrapper is LocalLibraryItem) {
|
||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
||||
} else {
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
cb(null)
|
||||
} else {
|
||||
cb(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
||||
var cost = Array(lhsLength) { it }
|
||||
var newCost = Array(lhsLength) { 0 }
|
||||
|
||||
for (i in 1..rhsLength-1) {
|
||||
newCost[0] = i
|
||||
|
||||
for (j in 1..lhsLength-1) {
|
||||
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
|
||||
|
||||
val costReplace = cost[j - 1] + match
|
||||
val costInsert = cost[j] + 1
|
||||
val costDelete = newCost[j - 1] + 1
|
||||
|
||||
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
|
||||
}
|
||||
|
||||
val swap = cost
|
||||
cost = newCost
|
||||
newCost = swap
|
||||
}
|
||||
|
||||
return cost[lhsLength - 1]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
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.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
data class MediaProgressSyncData(
|
||||
var timeListened:Long, // seconds
|
||||
var duration:Double, // seconds
|
||||
var currentTime:Double // seconds
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
var serverSyncAttempted:Boolean,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
|
||||
private var lastSyncTime:Long = 0
|
||||
private var failedSyncs:Int = 0
|
||||
|
||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||
if (playbackSession.id != currentSessionId) {
|
||||
Log.d(tag, "Playback session changed, reset timer")
|
||||
currentLocalMediaProgress = null
|
||||
listeningTimerTask?.cancel()
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
|
||||
failedSyncs = 0
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else if (playbackSession.id != currentSessionId) {
|
||||
currentLocalMediaProgress = null
|
||||
}
|
||||
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
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() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(playbackSession:PlaybackSession) {
|
||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||
MediaEventManager.playEvent(playbackSession)
|
||||
|
||||
start(playbackSession)
|
||||
}
|
||||
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) {
|
||||
reset()
|
||||
return cb()
|
||||
}
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) { syncResult ->
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.stopEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
reset()
|
||||
cb()
|
||||
}
|
||||
} else {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.stopEvent(playbackSession, null)
|
||||
}
|
||||
|
||||
reset()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
|
||||
Log.d(tag, "pause: Last sync time $lastSyncTime")
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) { // Current time should always be > 0 on pause
|
||||
sync(true, currentTime) { syncResult ->
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
||||
failedSyncs = 0
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.pauseEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
} else {
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
||||
failedSyncs = 0
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.pauseEvent(playbackSession, null)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun finished(cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
|
||||
reset()
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.finishedEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun seek() {
|
||||
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
|
||||
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag, "seek: Playback session not set")
|
||||
return
|
||||
}
|
||||
|
||||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||
}
|
||||
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
currentPlaybackSession?.let {
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||
if (lastSyncTime <= 0) {
|
||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||
if (diffSinceLastSync < 1000L) {
|
||||
return cb(null)
|
||||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||
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 {
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
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 with session id=${it.id}")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||
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 with session id=${currentSessionId}")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||
if (currentLocalMediaProgress == null) {
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
if (mediaProgress == null) {
|
||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||
} else {
|
||||
currentLocalMediaProgress = mediaProgress
|
||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||
}
|
||||
} else {
|
||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||
}
|
||||
|
||||
currentLocalMediaProgress?.let {
|
||||
if (it.progress.isNaN()) {
|
||||
Log.e(tag, "Invalid progress on local media progress")
|
||||
} else {
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
|
||||
failedSyncs = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.MediaType
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
||||
val itemParts = mutableListOf<DownloadItemPart>()
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
return itemParts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import java.io.File
|
||||
|
||||
data class DownloadItemPart(
|
||||
val id: String,
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
var moved:Boolean,
|
||||
var isMoving:Boolean,
|
||||
var failed:Boolean,
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
var progress: Long,
|
||||
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 {
|
||||
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
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||
downloadItemId,
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
moved = false,
|
||||
isMoving = false,
|
||||
failed = false,
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
finalDestinationSubfolder = subfolder,
|
||||
downloadId = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1,29 +1,28 @@
|
||||
package com.audiobookshelf.app
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
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, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
private var currentIconUri: Uri? = null
|
||||
private var currentBitmap: Bitmap? = null
|
||||
|
||||
private val glideOptions = RequestOptions()
|
||||
.fallback(R.drawable.icon)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
|
||||
@@ -45,13 +44,26 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
|
||||
if (currentIconUri.toString().startsWith("content://")) {
|
||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
currentBitmap
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
}
|
||||
null
|
||||
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
@@ -59,9 +71,8 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Block on downloading artwork.
|
||||
try {
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
@@ -71,9 +82,9 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.*
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
itemsInProgress: List<ItemInProgress>,
|
||||
libraries: List<Library>
|
||||
) {
|
||||
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())
|
||||
}.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())
|
||||
}.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())
|
||||
}.build()
|
||||
|
||||
if (itemsInProgress.isNotEmpty()) {
|
||||
rootList += continueListeningMetadata
|
||||
}
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
rootList += librariesMetadata
|
||||
|
||||
libraries.forEach { library ->
|
||||
val libraryMediaMetadata = library.getMediaMetadata()
|
||||
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||
children += libraryMediaMetadata
|
||||
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
rootList += downloadsMetadata
|
||||
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
}
|
||||
|
||||
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
||||
}
|
||||
|
||||
const val AUTO_BROWSE_ROOT = "/"
|
||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
@@ -1,32 +1,31 @@
|
||||
package com.audiobookshelf.app
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.appcompat.R
|
||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.google.android.exoplayer2.ext.cast.CastPlayer
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.gms.cast.Cast
|
||||
import com.google.android.gms.cast.CastDevice
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.*
|
||||
import com.google.android.gms.cast.framework.*
|
||||
import org.json.JSONObject
|
||||
import java.util.ArrayList
|
||||
|
||||
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
class CastManager constructor(val mainActivity:Activity) {
|
||||
private val tag = "CastManager"
|
||||
|
||||
private var newConnectionListener:SessionListener? = null
|
||||
private var mainActivity:Activity? = null
|
||||
private var playerNotificationService:PlayerNotificationService? = null
|
||||
private var newConnectionListener: SessionListener? = null
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
playerNotificationService.switchToPlayer(useCastPlayer)
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService?.switchToPlayer(useCastPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
|
||||
@@ -36,6 +35,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
* remote Cast receiver rather than play audio locally.
|
||||
*/
|
||||
override fun onCastSessionAvailable() {
|
||||
Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable")
|
||||
switchToPlayer(true)
|
||||
}
|
||||
|
||||
@@ -48,42 +48,39 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) {
|
||||
this.mainActivity = mainActivity
|
||||
fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
|
||||
this.playerNotificationService = playerNotificationService
|
||||
|
||||
mainActivity.runOnUiThread(object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
|
||||
mainActivity.runOnUiThread {
|
||||
val session: CastSession? = getSession()
|
||||
if (session == null) {
|
||||
// show the "choose a connection" dialog
|
||||
// Add the connection listener callback
|
||||
listenForConnection(callback)
|
||||
|
||||
val session: CastSession? = getSession()
|
||||
if (session == null) {
|
||||
// show the "choose a connection" dialog
|
||||
|
||||
// Add the connection listener callback
|
||||
listenForConnection(callback)
|
||||
|
||||
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
|
||||
builder.routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build()
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
builder.setOnCancelListener {
|
||||
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
callback.onCancel()
|
||||
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
|
||||
builder.routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build()
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
builder.setOnCancelListener {
|
||||
newConnectionListener?.let { ncl ->
|
||||
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
// We are are already connected, so show the "connection options" Dialog
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
if (session.castDevice != null) {
|
||||
builder.setTitle(session.castDevice.friendlyName)
|
||||
}
|
||||
builder.setOnDismissListener { callback.onCancel() }
|
||||
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
|
||||
builder.show()
|
||||
callback.onCancel()
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
// We are are already connected, so show the "connection options" Dialog
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
session.castDevice?.let {
|
||||
builder.setTitle(it.friendlyName)
|
||||
}
|
||||
builder.setOnDismissListener { callback.onCancel() }
|
||||
builder.setPositiveButton("Stop Casting") { _, _ -> endSession(true, null) }
|
||||
builder.show()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
abstract class RequestSessionCallback : ConnectionCallback {
|
||||
@@ -103,12 +100,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
|
||||
|
||||
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {
|
||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
||||
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
|
||||
Log.d(tag, "CAST END SESSION")
|
||||
// media.setSession(null)
|
||||
pluginCall?.resolve()
|
||||
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
|
||||
}
|
||||
}, CastSession::class.java)
|
||||
getSessionManager()!!.endCurrentSession(stopCasting)
|
||||
@@ -116,78 +111,47 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
open class SessionListener : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(castSession: CastSession?) {}
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionStartFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionEnding(castSession: CastSession?) {}
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionResuming(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionResumed(castSession: CastSession?, wasSuspended: Boolean) {}
|
||||
override fun onSessionResumeFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionSuspended(castSession: CastSession?, reason: Int) {}
|
||||
override fun onSessionStarting(castSession: CastSession) {}
|
||||
override fun onSessionStarted(castSession: CastSession, sessionId: String) {}
|
||||
override fun onSessionStartFailed(castSession: CastSession, error: Int) {}
|
||||
override fun onSessionEnding(castSession: CastSession) {}
|
||||
override fun onSessionEnded(castSession: CastSession, error: Int) {}
|
||||
override fun onSessionResuming(castSession: CastSession, sessionId: String) {}
|
||||
override fun onSessionResumed(castSession: CastSession, wasSuspended: Boolean) {}
|
||||
override fun onSessionResumeFailed(castSession: CastSession, error: Int) {}
|
||||
override fun onSessionSuspended(castSession: CastSession, reason: Int) {}
|
||||
}
|
||||
|
||||
private fun startRouteScan() {
|
||||
var connListener = object: ChromecastListener() {
|
||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
|
||||
}
|
||||
|
||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionRejoin")
|
||||
}
|
||||
|
||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaLoaded")
|
||||
}
|
||||
|
||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionEnd")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
||||
Log.d(tag, "CAST onMessageReceived")
|
||||
}
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
var 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
|
||||
// If there is at least one device available
|
||||
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
|
||||
|
||||
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
|
||||
|
||||
// Stop the scan
|
||||
stopRouteScan(this, null);
|
||||
stopRouteScan(this, null)
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true);
|
||||
connListener.onReceiverAvailableUpdate(true)
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession;
|
||||
var session = getSessionManager()?.currentCastSession
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
Log.d(tag, "LET SESSION KNOW ABOUT")
|
||||
// media.setSession(session);
|
||||
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No cast devices available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback.setMediaRouter(getMediaRouter())
|
||||
|
||||
callback.onFilteredRouteUpdate();
|
||||
callback.onFilteredRouteUpdate()
|
||||
|
||||
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
|
||||
getMediaRouter()?.addCallback(MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build(),
|
||||
callback,
|
||||
@@ -201,7 +165,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
fun onSessionEnd(jsonSession: JSONObject?)
|
||||
}
|
||||
|
||||
internal abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract fun onReceiverAvailableUpdate(available: Boolean)
|
||||
abstract fun onSessionRejoin(jsonSession: JSONObject?)
|
||||
|
||||
@@ -212,15 +176,19 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
|
||||
Log.d(tag, "stopRouteScan")
|
||||
if (callback == null) {
|
||||
completionCallback!!.run()
|
||||
completionCallback?.run()
|
||||
return
|
||||
}
|
||||
// ctx.runOnUiThread(Runnable {
|
||||
callback.stop()
|
||||
getMediaRouter()!!.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// })
|
||||
|
||||
// mainActivity.runOnUiThread {
|
||||
Log.d(tag, "Removing callback on media router")
|
||||
callback.stop()
|
||||
getMediaRouter()?.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
abstract class ScanCallback : MediaRouter.Callback() {
|
||||
@@ -271,7 +239,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
if (!route.isDefault
|
||||
&& !route.description.equals("Google Cast Multizone Member")
|
||||
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
&& route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
outRoutes.add(route)
|
||||
}
|
||||
}
|
||||
@@ -293,46 +261,48 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
|
||||
private fun listenForConnection(callback: ConnectionCallback) {
|
||||
// We should only ever have one of these listeners active at a time, so remove previous
|
||||
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
newConnectionListener?.let { ncl ->
|
||||
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
|
||||
}
|
||||
|
||||
newConnectionListener = object : SessionListener() {
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {
|
||||
Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}")
|
||||
override fun onSessionStarted(castSession: CastSession, sessionId: String) {
|
||||
Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}")
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
|
||||
try {
|
||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
|
||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||
addListener(playerNotificationService.getPlayerListener())
|
||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||
|
||||
playerNotificationService?.let {
|
||||
if (it.castPlayer == null) {
|
||||
Log.d(tag, "Initializing castPlayer on session started - switch to cast player")
|
||||
it.castPlayer = CastPlayer(castContext).apply {
|
||||
addListener(PlayerListener(it))
|
||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||
}
|
||||
switchToPlayer(true)
|
||||
} else {
|
||||
Log.d(tag, "castPlayer is already initialized on session started")
|
||||
}
|
||||
Log.d(tag, "CAST Cast Player Applied")
|
||||
switchToPlayer(true)
|
||||
} catch (e: Exception) {
|
||||
Log.i(tag, "Cast is not available on this device. " +
|
||||
"Exception thrown when attempting to obtain CastContext. " + e.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// media.setSession(castSession)
|
||||
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionStartFailed(errCode)) {
|
||||
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
|
||||
if (callback.onSessionStartFailed(error)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(errCode)) {
|
||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(error)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
newConnectionListener?.let {
|
||||
Log.d(tag, "Add session manager listener")
|
||||
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContext(): CastContext {
|
||||
@@ -344,7 +314,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
private fun getMediaRouter(): MediaRouter? {
|
||||
return mainActivity?.let { MediaRouter.getInstance(it) }
|
||||
return MediaRouter.getInstance(mainActivity)
|
||||
}
|
||||
|
||||
private fun getSession(): CastSession? {
|
||||
@@ -0,0 +1,978 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
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.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.trackselection.TrackSelection
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||
import com.google.android.exoplayer2.util.*
|
||||
import com.google.android.exoplayer2.util.Util.castNonNull
|
||||
import com.google.android.exoplayer2.video.VideoSize
|
||||
import com.google.android.gms.cast.*
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||
import com.google.android.gms.common.api.PendingResult
|
||||
import com.google.android.gms.common.api.ResultCallback
|
||||
import org.json.JSONObject
|
||||
|
||||
|
||||
class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
val tag = "CastPlayer"
|
||||
|
||||
private val RENDERER_COUNT = 3
|
||||
private val RENDERER_INDEX_VIDEO = 0
|
||||
private val RENDERER_INDEX_AUDIO = 1
|
||||
private val RENDERER_INDEX_TEXT = 2
|
||||
private val PROGRESS_REPORT_PERIOD_MS: Long = 1000
|
||||
private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray()
|
||||
private val EMPTY_TRACK_ID_ARRAY = LongArray(0)
|
||||
|
||||
private val seekBackIncrementMs: Long = 5000L
|
||||
private val seekForwardIncrementMs: Long = 5000L
|
||||
private var myPlayWhenReady:Boolean = false
|
||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||
private var myAvailableCommands: Commands
|
||||
private var myCurrentTracksInfo: TracksInfo
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
||||
var remoteMediaClient:RemoteMediaClient? = null
|
||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
||||
var myCurrentTimeline: CastTimeline
|
||||
val timelineTracker: CastTimelineTracker
|
||||
val period: Timeline.Period
|
||||
var listeners: ListenerSet<Player.Listener>
|
||||
|
||||
/* package */
|
||||
val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder()
|
||||
.addAll(
|
||||
COMMAND_PLAY_PAUSE,
|
||||
COMMAND_PREPARE,
|
||||
COMMAND_STOP,
|
||||
COMMAND_SEEK_TO_DEFAULT_POSITION,
|
||||
COMMAND_SEEK_TO_MEDIA_ITEM,
|
||||
COMMAND_SET_REPEAT_MODE,
|
||||
COMMAND_SET_SPEED_AND_PITCH,
|
||||
COMMAND_GET_CURRENT_MEDIA_ITEM,
|
||||
COMMAND_GET_TIMELINE,
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACK_INFOS)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
var statusListener:StatusListener
|
||||
|
||||
val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString()
|
||||
var lastReportedPositionMs = 0L
|
||||
|
||||
private var currentMediaItemIndex = 0
|
||||
private var pendingMediaItemRemovalPosition:PositionInfo? = null
|
||||
private var pendingSeekCount = 0
|
||||
private var pendingSeekWindowIndex = C.INDEX_UNSET
|
||||
private var pendingSeekPositionMs = 0L
|
||||
|
||||
init {
|
||||
Log.d(tag, "Init CastPlayer")
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
statusListener = StatusListener()
|
||||
timelineTracker = CastTimelineTracker()
|
||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||
period = Timeline.Period()
|
||||
myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build()
|
||||
|
||||
listeners = ListenerSet(
|
||||
Looper.getMainLooper(),
|
||||
Clock.DEFAULT
|
||||
) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) }
|
||||
|
||||
val sessionManager = castContext.sessionManager
|
||||
sessionManager.addSessionManagerListener(statusListener, CastSession::class.java)
|
||||
val session = sessionManager.currentCastSession
|
||||
setRemoteMediaClient(session?.remoteMediaClient)
|
||||
}
|
||||
|
||||
fun load(mediaItems:List<MediaItem>, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) {
|
||||
Log.d(tag, "Load called")
|
||||
|
||||
if (remoteMediaClient == null) {
|
||||
Log.d(tag, "Remote Media Client not set")
|
||||
return
|
||||
}
|
||||
|
||||
currentMediaItems = mediaItems
|
||||
|
||||
var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) }
|
||||
|
||||
var queueData = MediaQueueData.Builder().apply {
|
||||
setItems(mediaQueueItems)
|
||||
setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES)
|
||||
setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF)
|
||||
setStartIndex(startIndex)
|
||||
setStartTime(startTime)
|
||||
}.build()
|
||||
|
||||
var loadRequestData = MediaLoadRequestData.Builder().apply {
|
||||
setPlaybackRate(playbackRate.toDouble())
|
||||
setQueueData(queueData)
|
||||
setAutoplay(playWhenReady)
|
||||
setCurrentTime(startTime)
|
||||
}.build()
|
||||
|
||||
remoteMediaClient?.load(loadRequestData)?.setResultCallback {
|
||||
Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}")
|
||||
}
|
||||
|
||||
Log.d(tag, "Loaded cast player request data $loadRequestData")
|
||||
}
|
||||
|
||||
private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
|
||||
// The MediaQueueItem you build is expected to be in the tag.
|
||||
return (mediaItem.localConfiguration!!.tag as MediaQueueItem?)!!
|
||||
}
|
||||
|
||||
@JvmName("setRemoteMediaClient1")
|
||||
private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) {
|
||||
if (this.remoteMediaClient === remoteMediaClient) {
|
||||
// Do nothing.
|
||||
return
|
||||
}
|
||||
if (this.remoteMediaClient != null) {
|
||||
this.remoteMediaClient?.unregisterCallback(statusListener)
|
||||
this.remoteMediaClient?.removeProgressListener(statusListener)
|
||||
}
|
||||
this.remoteMediaClient = remoteMediaClient
|
||||
if (remoteMediaClient != null) {
|
||||
sessionAvailabilityListener?.let {
|
||||
it.onCastSessionAvailable()
|
||||
}
|
||||
|
||||
remoteMediaClient.registerCallback(statusListener)
|
||||
remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS)
|
||||
updateInternalStateAndNotifyIfChanged()
|
||||
} else {
|
||||
updateTimelineAndNotifyIfChanged()
|
||||
sessionAvailabilityListener?.let {
|
||||
it.onCastSessionUnavailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlayerStateAndNotifyIfChanged(
|
||||
playWhenReady: Boolean,
|
||||
@PlayWhenReadyChangeReason playWhenReadyChangeReason: Int,
|
||||
@Player.State playbackState: Int) {
|
||||
|
||||
val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady
|
||||
val playWhenReadyChanged = this.playWhenReady != playWhenReady
|
||||
val playbackStateChanged = this.currentPlaybackState != playbackState
|
||||
|
||||
Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged")
|
||||
if (playWhenReadyChanged || playbackStateChanged) {
|
||||
this.currentPlaybackState = playbackState
|
||||
this.myPlayWhenReady = playWhenReady
|
||||
|
||||
if (playbackStateChanged) {
|
||||
Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged")
|
||||
listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) {
|
||||
it.onPlaybackStateChanged(playbackState)
|
||||
}
|
||||
}
|
||||
|
||||
if (playWhenReadyChanged) {
|
||||
listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) {
|
||||
it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason)
|
||||
}
|
||||
}
|
||||
|
||||
val isPlaying = playbackState == STATE_READY && playWhenReady
|
||||
if (wasPlaying != isPlaying) {
|
||||
Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying")
|
||||
listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) {
|
||||
it.onIsPlayingChanged(isPlaying)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Is playing and was playing are equal $wasPlaying")
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes")
|
||||
}
|
||||
listeners.flushEvents()
|
||||
}
|
||||
|
||||
private fun updatePlayerStateAndNotifyIfChanged() {
|
||||
var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true
|
||||
|
||||
@PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
|
||||
// We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
|
||||
setPlayerStateAndNotifyIfChanged(
|
||||
newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient))
|
||||
}
|
||||
|
||||
private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int {
|
||||
return when (remoteMediaClient?.playerState) {
|
||||
MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING
|
||||
MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY
|
||||
MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE
|
||||
else -> STATE_IDLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlaybackRateAndNotifyIfChanged() {
|
||||
val mediaStatus = remoteMediaClient!!.mediaStatus
|
||||
val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed
|
||||
if (speed > 0.0f) {
|
||||
// Set the speed if not paused.
|
||||
setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) {
|
||||
if (this.playbackParameters == playbackParameters) {
|
||||
return
|
||||
}
|
||||
this.playbackParameters = playbackParameters
|
||||
listeners.queueEvent(
|
||||
EVENT_PLAYBACK_PARAMETERS_CHANGED
|
||||
) { listener -> listener.onPlaybackParametersChanged(playbackParameters) }
|
||||
listeners.flushEvents()
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
private fun updateTimelineAndNotifyIfChanged(): Boolean {
|
||||
val oldTimeline: Timeline = this.myCurrentTimeline
|
||||
val oldWindowIndex = currentMediaItemIndex
|
||||
var playingPeriodChanged = false
|
||||
if (updateTimeline()) {
|
||||
val timeline: Timeline = this.myCurrentTimeline
|
||||
// Call onTimelineChanged.
|
||||
listeners.queueEvent(
|
||||
EVENT_TIMELINE_CHANGED
|
||||
) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) }
|
||||
|
||||
// Call onPositionDiscontinuity if required.
|
||||
val currentTimeline = currentTimeline
|
||||
var playingPeriodRemoved = false
|
||||
if (!oldTimeline.isEmpty) {
|
||||
val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid)
|
||||
playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET
|
||||
}
|
||||
if (playingPeriodRemoved) {
|
||||
var oldPosition: PositionInfo = getCurrentPositionInfo()
|
||||
if (pendingMediaItemRemovalPosition != null) {
|
||||
pendingMediaItemRemovalPosition?.let {
|
||||
oldPosition = it
|
||||
}
|
||||
pendingMediaItemRemovalPosition = null
|
||||
} else {
|
||||
// If the media item has been removed by another client, we don't know the removal
|
||||
// position. We use the current position as a fallback.
|
||||
oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
|
||||
oldTimeline.getWindow(period.windowIndex, window)
|
||||
oldPosition = PositionInfo(
|
||||
window.uid,
|
||||
period.windowIndex,
|
||||
window.mediaItem,
|
||||
period.uid,
|
||||
period.windowIndex,
|
||||
currentPosition,
|
||||
contentPosition,
|
||||
C.INDEX_UNSET,/* adGroupIndex= */
|
||||
C.INDEX_UNSET) /* adIndexInAdGroup= */
|
||||
}
|
||||
|
||||
val newPosition: PositionInfo = getCurrentPositionInfo()
|
||||
listeners.queueEvent(
|
||||
EVENT_POSITION_DISCONTINUITY
|
||||
) { listener: Player.Listener ->
|
||||
listener.onPositionDiscontinuity(
|
||||
oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE)
|
||||
}
|
||||
}
|
||||
|
||||
// Call onMediaItemTransition if required.
|
||||
playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved
|
||||
if (playingPeriodChanged) {
|
||||
listeners.queueEvent(
|
||||
EVENT_MEDIA_ITEM_TRANSITION
|
||||
) { listener: Player.Listener ->
|
||||
listener.onMediaItemTransition(
|
||||
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)
|
||||
}
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
}
|
||||
return playingPeriodChanged
|
||||
}
|
||||
|
||||
private fun updateInternalStateAndNotifyIfChanged() {
|
||||
if (remoteMediaClient == null) {
|
||||
// There is no session. We leave the state of the player as it is now.
|
||||
return
|
||||
}
|
||||
|
||||
val oldWindowIndex = this.currentMediaItemIndex
|
||||
val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null
|
||||
updatePlayerStateAndNotifyIfChanged()
|
||||
// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null)
|
||||
updatePlaybackRateAndNotifyIfChanged()
|
||||
val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged()
|
||||
val currentTimeline = currentTimeline
|
||||
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline)
|
||||
val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null
|
||||
if (!playingPeriodChangedByTimelineChange
|
||||
&& !Util.areEqual(oldPeriodUid, currentPeriodUid)
|
||||
&& pendingSeekCount == 0) {
|
||||
// Report discontinuity and media item auto transition.
|
||||
currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
|
||||
currentTimeline.getWindow(oldWindowIndex, window)
|
||||
val windowDurationMs = window.durationMs
|
||||
val oldPosition = PositionInfo(
|
||||
window.uid,
|
||||
period.windowIndex,
|
||||
window.mediaItem,
|
||||
period.uid,
|
||||
period.windowIndex, /* positionMs= */
|
||||
windowDurationMs, /* contentPositionMs= */
|
||||
windowDurationMs, /* adGroupIndex= */
|
||||
C.INDEX_UNSET, /* adIndexInAdGroup= */
|
||||
C.INDEX_UNSET)
|
||||
currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true)
|
||||
currentTimeline.getWindow(currentMediaItemIndex, window)
|
||||
val newPosition = PositionInfo(
|
||||
window.uid,
|
||||
period.windowIndex,
|
||||
window.mediaItem,
|
||||
period.uid,
|
||||
period.windowIndex, /* positionMs= */
|
||||
window.defaultPositionMs, /* contentPositionMs= */
|
||||
window.defaultPositionMs, /* adGroupIndex= */
|
||||
C.INDEX_UNSET, /* adIndexInAdGroup= */
|
||||
C.INDEX_UNSET)
|
||||
listeners.queueEvent(
|
||||
EVENT_POSITION_DISCONTINUITY
|
||||
) { listener: Listener ->
|
||||
listener.onPositionDiscontinuity(
|
||||
oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION)
|
||||
}
|
||||
listeners.queueEvent(
|
||||
EVENT_MEDIA_ITEM_TRANSITION
|
||||
) { listener: Listener ->
|
||||
listener.onMediaItemTransition(
|
||||
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO)
|
||||
}
|
||||
}
|
||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||
listeners.queueEvent(
|
||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
|
||||
/** Updates the internal tracks and selection and returns whether they have changed. */
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean {
|
||||
if (remoteMediaClient == null) {
|
||||
// There is no session. We leave the state of the player as it is now.
|
||||
return false
|
||||
}
|
||||
val mediaStatus = getMediaStatus()
|
||||
val mediaInfo = mediaStatus?.mediaInfo
|
||||
val castMediaTracks = mediaInfo?.mediaTracks
|
||||
if (castMediaTracks == null || castMediaTracks.isEmpty()) {
|
||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
||||
return hasChanged
|
||||
}
|
||||
var activeTrackIds = mediaStatus.activeTrackIds
|
||||
if (activeTrackIds == null) {
|
||||
activeTrackIds = EMPTY_TRACK_ID_ARRAY
|
||||
}
|
||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
||||
for (i in castMediaTracks.indices) {
|
||||
val mediaTrack = castMediaTracks[i]
|
||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
||||
val id = mediaTrack.id
|
||||
val trackType = MimeTypes.getTrackType(mediaTrack.contentType)
|
||||
val rendererIndex: Int = getRendererIndexForTrackType(trackType)
|
||||
val supported = rendererIndex != C.INDEX_UNSET
|
||||
val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null
|
||||
if (selected) {
|
||||
trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) }
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||
val newTrackGroups = TrackGroupArray(*tg)
|
||||
val newTrackSelections = TrackSelectionArray(*ts)
|
||||
val newTracksInfo = TracksInfo(tgi)
|
||||
if (newTrackGroups != currentTrackGroups
|
||||
|| newTrackSelections != currentTrackSelections
|
||||
|| newTracksInfo != currentTracksInfo) {
|
||||
myCurrentTrackSelections = newTrackSelections
|
||||
myCurrentTrackGroups = newTrackGroups
|
||||
myCurrentTracksInfo = newTracksInfo
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [Format] instance containing all information contained in the given [ ] object.
|
||||
*
|
||||
* @param mediaTrack The [MediaTrack].
|
||||
* @return The equivalent [Format].
|
||||
*/
|
||||
fun mediaTrackToFormat(mediaTrack: MediaTrack): Format {
|
||||
return Format.Builder()
|
||||
.setId(mediaTrack.contentId)
|
||||
.setContainerMimeType(mediaTrack.contentType)
|
||||
.setLanguage(mediaTrack.language)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int {
|
||||
return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET
|
||||
}
|
||||
|
||||
private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean {
|
||||
for (activeTrackId in activeTrackIds) {
|
||||
if (activeTrackId == id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getCurrentPositionInfo(): PositionInfo {
|
||||
val currentTimeline = currentTimeline
|
||||
var newPeriodUid: Any? = null
|
||||
var newWindowUid: Any? = null
|
||||
var newMediaItem: MediaItem? = null
|
||||
if (!currentTimeline.isEmpty) {
|
||||
newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid
|
||||
newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid
|
||||
newMediaItem = window.mediaItem
|
||||
}
|
||||
return PositionInfo(
|
||||
newWindowUid,
|
||||
currentMediaItemIndex,
|
||||
newMediaItem,
|
||||
newPeriodUid,
|
||||
currentPeriodIndex,
|
||||
currentPosition,
|
||||
contentPosition,
|
||||
C.INDEX_UNSET,/* adGroupIndex= */
|
||||
C.INDEX_UNSET) /* adIndexInAdGroup= */
|
||||
}
|
||||
|
||||
private fun updateTimeline(): Boolean {
|
||||
val oldTimeline = this.myCurrentTimeline
|
||||
val status: MediaStatus? = getMediaStatus()
|
||||
|
||||
remoteMediaClient?.let {
|
||||
this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE
|
||||
}
|
||||
|
||||
val timelineChanged = oldTimeline != this.myCurrentTimeline
|
||||
if (timelineChanged) {
|
||||
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline)
|
||||
Log.d(tag, "timelineChanged $currentMediaItemIndex")
|
||||
}
|
||||
return timelineChanged
|
||||
}
|
||||
|
||||
private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int {
|
||||
if (remoteMediaClient == null) {
|
||||
return 0
|
||||
}
|
||||
var currentWindowIndex = C.INDEX_UNSET
|
||||
val currentItem = remoteMediaClient.currentItem
|
||||
if (currentItem != null) {
|
||||
currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId)
|
||||
}
|
||||
if (currentWindowIndex == C.INDEX_UNSET) {
|
||||
// The timeline is empty. Fall back to index 0.
|
||||
currentWindowIndex = 0
|
||||
}
|
||||
return currentWindowIndex
|
||||
}
|
||||
|
||||
private fun getMediaStatus():MediaStatus? {
|
||||
return remoteMediaClient?.mediaStatus
|
||||
}
|
||||
|
||||
@JvmName("setSessionAvailabilityListener1")
|
||||
fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) {
|
||||
sessionAvailabilityListener = listener
|
||||
}
|
||||
|
||||
override fun getApplicationLooper(): Looper {
|
||||
return Looper.getMainLooper()
|
||||
}
|
||||
|
||||
override fun addListener(listener: Player.Listener) {
|
||||
listeners.add(listener)
|
||||
Log.d(tag, "addListener player listener $listener")
|
||||
}
|
||||
|
||||
override fun removeListener(listener: Player.Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, startWindowIndex: Int, startPositionMs: Long) {
|
||||
}
|
||||
|
||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
||||
}
|
||||
|
||||
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {
|
||||
}
|
||||
|
||||
override fun removeMediaItems(fromIndex: Int, toIndex: Int) {
|
||||
Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex")
|
||||
}
|
||||
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
return myAvailableCommands
|
||||
}
|
||||
|
||||
override fun prepare() {
|
||||
}
|
||||
|
||||
override fun getPlaybackState(): Int {
|
||||
return currentPlaybackState
|
||||
}
|
||||
|
||||
override fun getPlaybackSuppressionReason(): Int {
|
||||
return Player.PLAYBACK_SUPPRESSION_REASON_NONE
|
||||
}
|
||||
|
||||
override fun getPlayerError(): PlaybackException? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
||||
setPlayerStateAndNotifyIfChanged(
|
||||
playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState)
|
||||
|
||||
listeners.flushEvents()
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlayerStateAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
pendingResult.setResultCallback(resultCb)
|
||||
}
|
||||
|
||||
override fun getPlayWhenReady(): Boolean {
|
||||
return myPlayWhenReady
|
||||
}
|
||||
|
||||
override fun setRepeatMode(repeatMode: Int) {
|
||||
}
|
||||
|
||||
override fun getRepeatMode(): Int {
|
||||
return Player.REPEAT_MODE_OFF
|
||||
}
|
||||
|
||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
|
||||
}
|
||||
|
||||
override fun getShuffleModeEnabled(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun isLoading(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
||||
|
||||
var 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")
|
||||
}
|
||||
if (--pendingSeekCount == 0) {
|
||||
currentMediaItemIndex = pendingSeekWindowIndex
|
||||
Log.d(tag, "seekTo CB $currentMediaItemIndex")
|
||||
pendingSeekWindowIndex = C.INDEX_UNSET
|
||||
pendingSeekPositionMs = C.TIME_UNSET
|
||||
|
||||
// Playback state change will send metadata to client and stop seek loading
|
||||
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
|
||||
}
|
||||
}
|
||||
|
||||
val mediaStatus = getMediaStatus()
|
||||
|
||||
// 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
|
||||
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()
|
||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
||||
}
|
||||
val oldPosition = getCurrentPositionInfo()
|
||||
pendingSeekCount++
|
||||
pendingSeekWindowIndex = mediaItemIndex
|
||||
pendingSeekPositionMs = positionMsFinal
|
||||
val newPosition = getCurrentPositionInfo()
|
||||
listeners.queueEvent(
|
||||
EVENT_POSITION_DISCONTINUITY
|
||||
) { listener: Player.Listener ->
|
||||
listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK)
|
||||
}
|
||||
if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) {
|
||||
val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem
|
||||
listeners.queueEvent(
|
||||
EVENT_MEDIA_ITEM_TRANSITION
|
||||
) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) }
|
||||
}
|
||||
updateAvailableCommandsAndNotifyIfChanged()
|
||||
} else if (pendingSeekCount == 0) {
|
||||
Log.w(tag, "seekTo Media Status is null")
|
||||
// Playback state change will send metadata to client and stop seek loading
|
||||
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
|
||||
}
|
||||
listeners.flushEvents()
|
||||
}
|
||||
|
||||
private fun updateAvailableCommandsAndNotifyIfChanged() {
|
||||
val previousAvailableCommands = availableCommands
|
||||
myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS)
|
||||
if (availableCommands != previousAvailableCommands) {
|
||||
listeners.queueEvent(
|
||||
EVENT_AVAILABLE_COMMANDS_CHANGED
|
||||
) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSeekBackIncrement(): Long {
|
||||
return seekBackIncrementMs
|
||||
}
|
||||
|
||||
override fun getSeekForwardIncrement(): Long {
|
||||
return seekForwardIncrementMs
|
||||
}
|
||||
|
||||
override fun getMaxSeekToPreviousPosition(): Long {
|
||||
return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS
|
||||
}
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
||||
val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed)
|
||||
setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters)
|
||||
listeners.flushEvents()
|
||||
|
||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
||||
|
||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||
updatePlaybackRateAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
pendingResult?.setResultCallback(resultCb)
|
||||
}
|
||||
|
||||
override fun getPlaybackParameters(): PlaybackParameters {
|
||||
return playbackParameters
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
Log.d(tag, "stop CastPlayer")
|
||||
currentPlaybackState = Player.STATE_IDLE
|
||||
remoteMediaClient?.stop()
|
||||
}
|
||||
|
||||
override fun stop(reset: Boolean) {
|
||||
stop()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
val sessionManager = castContext.sessionManager
|
||||
sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java)
|
||||
sessionManager.endCurrentSession(false)
|
||||
}
|
||||
|
||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
||||
return this.myCurrentTrackGroups
|
||||
}
|
||||
|
||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
||||
return this.myCurrentTrackSelections
|
||||
}
|
||||
|
||||
override fun getCurrentTracksInfo(): TracksInfo {
|
||||
return this.myCurrentTracksInfo
|
||||
}
|
||||
|
||||
override fun getTrackSelectionParameters(): TrackSelectionParameters {
|
||||
return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
|
||||
}
|
||||
|
||||
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {}
|
||||
|
||||
override fun getMediaMetadata(): MediaMetadata {
|
||||
return MediaMetadata.EMPTY
|
||||
}
|
||||
|
||||
override fun getPlaylistMetadata(): MediaMetadata {
|
||||
return MediaMetadata.EMPTY
|
||||
}
|
||||
|
||||
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {
|
||||
}
|
||||
|
||||
override fun getCurrentTimeline(): Timeline {
|
||||
return this.myCurrentTimeline
|
||||
}
|
||||
|
||||
override fun getCurrentPeriodIndex(): Int {
|
||||
return getCurrentMediaItemIndex()
|
||||
}
|
||||
|
||||
override fun getCurrentMediaItemIndex(): Int {
|
||||
return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex
|
||||
}
|
||||
|
||||
override fun getDuration(): Long {
|
||||
return contentDuration
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long {
|
||||
return remoteMediaClient?.approximateStreamPosition ?: 0L
|
||||
}
|
||||
|
||||
override fun getBufferedPosition(): Long {
|
||||
return currentPosition
|
||||
}
|
||||
|
||||
override fun getTotalBufferedDuration(): Long {
|
||||
return 0L
|
||||
}
|
||||
|
||||
override fun isPlayingAd(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getCurrentAdGroupIndex(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun getCurrentAdIndexInAdGroup(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun getContentPosition(): Long {
|
||||
return currentPosition
|
||||
}
|
||||
|
||||
override fun getContentBufferedPosition(): Long {
|
||||
return currentPosition
|
||||
}
|
||||
|
||||
override fun getAudioAttributes(): AudioAttributes {
|
||||
return AudioAttributes.DEFAULT
|
||||
}
|
||||
|
||||
override fun setVolume(audioVolume: Float) {
|
||||
|
||||
}
|
||||
|
||||
override fun getVolume(): Float {
|
||||
return 0F
|
||||
}
|
||||
|
||||
override fun clearVideoSurface() {
|
||||
}
|
||||
|
||||
override fun clearVideoSurface(surface: Surface?) {
|
||||
}
|
||||
|
||||
override fun setVideoSurface(surface: Surface?) {
|
||||
}
|
||||
|
||||
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
|
||||
}
|
||||
|
||||
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
|
||||
}
|
||||
|
||||
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
|
||||
}
|
||||
|
||||
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
|
||||
}
|
||||
|
||||
override fun setVideoTextureView(textureView: TextureView?) {
|
||||
|
||||
}
|
||||
|
||||
override fun clearVideoTextureView(textureView: TextureView?) {
|
||||
|
||||
}
|
||||
|
||||
override fun getVideoSize(): VideoSize {
|
||||
return VideoSize.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getCurrentCues(): MutableList<Cue> {
|
||||
return mutableListOf()
|
||||
}
|
||||
|
||||
override fun getDeviceInfo(): DeviceInfo {
|
||||
return DeviceInfo.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getDeviceVolume(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun isDeviceMuted(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun setDeviceVolume(volume: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun increaseDeviceVolume() {
|
||||
|
||||
}
|
||||
|
||||
override fun decreaseDeviceVolume() {
|
||||
|
||||
}
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean) {
|
||||
|
||||
}
|
||||
|
||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
||||
val TAG = "StatusListener"
|
||||
|
||||
// RemoteMediaClient.ProgressListener implementation.
|
||||
override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) {
|
||||
lastReportedPositionMs = progressMs
|
||||
}
|
||||
|
||||
// RemoteMediaClient.Callback implementation.
|
||||
override fun onStatusUpdated() {
|
||||
Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount)
|
||||
if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) {
|
||||
Log.d(TAG, "StatusListener CastPlayer STATE_IDLE")
|
||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) {
|
||||
Log.d(TAG, "StatusListener CastPlayer BUFFERING")
|
||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) {
|
||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING")
|
||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) {
|
||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED")
|
||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) {
|
||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING")
|
||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) {
|
||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN")
|
||||
}
|
||||
|
||||
updateInternalStateAndNotifyIfChanged()
|
||||
}
|
||||
|
||||
override fun onMetadataUpdated() {}
|
||||
override fun onQueueStatusUpdated() {
|
||||
Log.d(TAG, "onQueueStatusUpdated")
|
||||
updateTimelineAndNotifyIfChanged()
|
||||
listeners.flushEvents()
|
||||
}
|
||||
|
||||
override fun onPreloadStatusUpdated() {}
|
||||
override fun onSendingRemoteMediaRequest() {}
|
||||
override fun onAdBreakStatusUpdated() {}
|
||||
|
||||
// SessionManagerListener implementation.
|
||||
override fun onSessionStarted(castSession: CastSession, s: String) {
|
||||
Log.d(TAG, "StatusListener onSessionStarted")
|
||||
setRemoteMediaClient(castSession.remoteMediaClient)
|
||||
}
|
||||
|
||||
override fun onSessionResumed(castSession: CastSession, b: Boolean) {
|
||||
setRemoteMediaClient(castSession.remoteMediaClient)
|
||||
}
|
||||
|
||||
override fun onSessionEnded(castSession: CastSession, i: Int) {
|
||||
Log.d(TAG, "StatusListener onSessionEnded")
|
||||
setRemoteMediaClient(null)
|
||||
}
|
||||
|
||||
override fun onSessionSuspended(castSession: CastSession, i: Int) {
|
||||
setRemoteMediaClient(null)
|
||||
}
|
||||
|
||||
override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Session resume failed. Error code "
|
||||
+ statusCode)
|
||||
}
|
||||
|
||||
override fun onSessionStarting(castSession: CastSession) {
|
||||
Log.d(TAG, "StatusListener onSessionStarting")
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) {
|
||||
Log.e(
|
||||
TAG,
|
||||
("Session start failed. Error code "
|
||||
+ statusCode))
|
||||
}
|
||||
|
||||
override fun onSessionEnding(castSession: CastSession) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
override fun onSessionResuming(castSession: CastSession, s: String) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.SparseArray
|
||||
import android.util.SparseIntArray
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.Timeline
|
||||
import java.util.*
|
||||
|
||||
/** A [Timeline] for Cast media queues. */ /* package */
|
||||
class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray<ItemData>) : Timeline() {
|
||||
/** Holds [Timeline] related data for a Cast media item. */
|
||||
class ItemData(
|
||||
/** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */
|
||||
val durationUs: Long,
|
||||
/**
|
||||
* The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown.
|
||||
*/
|
||||
var defaultPositionUs: Long, isLive: Boolean) {
|
||||
/** Whether the item is live content, or `false` if unknown. */
|
||||
val isLive: Boolean = isLive
|
||||
|
||||
/**
|
||||
* Returns a copy of this instance with the given values.
|
||||
*
|
||||
* @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown.
|
||||
* @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET]
|
||||
* if unknown.
|
||||
* @param isLive Whether the item is live, or `false` if unknown.
|
||||
*/
|
||||
fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData {
|
||||
return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) {
|
||||
this
|
||||
} else ItemData(durationUs, defaultPositionUs, isLive)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Holds no media information. */
|
||||
val EMPTY = ItemData( /* durationUs= */
|
||||
C.TIME_UNSET, /* defaultPositionUs= */
|
||||
C.TIME_UNSET, /* isLive= */
|
||||
false)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val idsToIndex: SparseIntArray
|
||||
private val ids: IntArray
|
||||
private val durationsUs: LongArray
|
||||
private val defaultPositionsUs: LongArray
|
||||
private val isLive: BooleanArray
|
||||
|
||||
// Timeline implementation.
|
||||
override fun getWindowCount(): Int {
|
||||
return ids.size
|
||||
}
|
||||
|
||||
override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
|
||||
val durationUs = durationsUs[windowIndex]
|
||||
val isDynamic = durationUs == C.TIME_UNSET
|
||||
val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build()
|
||||
return window.set( /* uid= */
|
||||
ids[windowIndex], /* mediaItem= */
|
||||
mediaItem, /* manifest= */
|
||||
null, /* presentationStartTimeMs= */
|
||||
C.TIME_UNSET, /* windowStartTimeMs= */
|
||||
C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */
|
||||
C.TIME_UNSET, /* isSeekable= */
|
||||
!isDynamic,
|
||||
isDynamic,
|
||||
if (isLive[windowIndex]) mediaItem.liveConfiguration else null,
|
||||
defaultPositionsUs[windowIndex],
|
||||
durationUs, /* firstPeriodIndex= */
|
||||
windowIndex, /* lastPeriodIndex= */
|
||||
windowIndex, /* positionInFirstPeriodUs= */
|
||||
0)
|
||||
}
|
||||
|
||||
override fun getPeriodCount(): Int {
|
||||
return ids.size
|
||||
}
|
||||
|
||||
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
|
||||
val id = ids[periodIndex]
|
||||
return period.set(id, id, periodIndex, durationsUs[periodIndex], 0)
|
||||
}
|
||||
|
||||
override fun getIndexOfPeriod(uid: Any): Int {
|
||||
return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET
|
||||
}
|
||||
|
||||
override fun getUidOfPeriod(periodIndex: Int): Int {
|
||||
return ids[periodIndex]
|
||||
}
|
||||
|
||||
// equals and hashCode implementations.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) {
|
||||
return true
|
||||
} else if (other !is CastTimeline) {
|
||||
return false
|
||||
}
|
||||
val that = other
|
||||
return (Arrays.equals(ids, that.ids)
|
||||
&& Arrays.equals(durationsUs, that.durationsUs)
|
||||
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
|
||||
&& Arrays.equals(isLive, that.isLive))
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = Arrays.hashCode(ids)
|
||||
result = 31 * result + Arrays.hashCode(durationsUs)
|
||||
result = 31 * result + Arrays.hashCode(defaultPositionsUs)
|
||||
result = 31 * result + Arrays.hashCode(isLive)
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** [Timeline] for a cast queue that has no items. */
|
||||
val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Cast timeline from the given data.
|
||||
*
|
||||
* @param itemIds The ids of the items in the timeline.
|
||||
* @param itemIdToData Maps item ids to [ItemData].
|
||||
*/
|
||||
init {
|
||||
val itemCount = itemIds.size
|
||||
idsToIndex = SparseIntArray(itemCount)
|
||||
ids = itemIds.copyOf(itemCount)
|
||||
durationsUs = LongArray(itemCount)
|
||||
defaultPositionsUs = LongArray(itemCount)
|
||||
isLive = BooleanArray(itemCount)
|
||||
for (i in ids.indices) {
|
||||
val id = ids[i]
|
||||
idsToIndex.put(id, i)
|
||||
val data = itemIdToData[id, ItemData.EMPTY]
|
||||
durationsUs[i] = data.durationUs
|
||||
defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs
|
||||
isLive[i] = data.isLive
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2018 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.util.SparseArray
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||
|
||||
/**
|
||||
* Creates [CastTimelines][CastTimeline] from cast receiver app status updates.
|
||||
*
|
||||
*
|
||||
* This class keeps track of the duration reported by the current item to fill any missing
|
||||
* durations in the media queue items [See internal: b/65152553].
|
||||
*/
|
||||
/* package */
|
||||
class CastTimelineTracker {
|
||||
private val itemIdToData: SparseArray<CastTimeline.ItemData>
|
||||
|
||||
/**
|
||||
* Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`.
|
||||
*
|
||||
*
|
||||
* Returned timelines may contain values obtained from `remoteMediaClient` in previous
|
||||
* invocations of this method.
|
||||
*
|
||||
* @param remoteMediaClient The Cast media client.
|
||||
* @return A [CastTimeline] that represents the given `remoteMediaClient` status.
|
||||
*/
|
||||
fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline {
|
||||
val itemIds = remoteMediaClient.mediaQueue.itemIds
|
||||
if (itemIds.size > 0) {
|
||||
// Only remove unused items when there is something in the queue to avoid removing all entries
|
||||
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
|
||||
removeUnusedItemDataEntries(itemIds)
|
||||
}
|
||||
|
||||
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
|
||||
val mediaStatus = remoteMediaClient.mediaStatus
|
||||
?: return CastTimeline.EMPTY_CAST_TIMELINE
|
||||
val currentItemId = mediaStatus.currentItemId
|
||||
updateItemData(
|
||||
currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET)
|
||||
for (item in mediaStatus.queueItems) {
|
||||
val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong()
|
||||
updateItemData(item.itemId, item.media, defaultPositionUs)
|
||||
}
|
||||
return CastTimeline(itemIds, itemIdToData)
|
||||
}
|
||||
|
||||
private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) {
|
||||
var defaultPositionUs = defaultPositionUs
|
||||
val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY]
|
||||
var durationUs = getStreamDurationUs(mediaInfo)
|
||||
if (durationUs == C.TIME_UNSET) {
|
||||
durationUs = previousData.durationUs
|
||||
}
|
||||
val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE
|
||||
if (defaultPositionUs == C.TIME_UNSET) {
|
||||
defaultPositionUs = previousData.defaultPositionUs
|
||||
}
|
||||
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive))
|
||||
}
|
||||
|
||||
private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long {
|
||||
if (mediaInfo == null) {
|
||||
return C.TIME_UNSET
|
||||
}
|
||||
val durationMs = mediaInfo.streamDuration
|
||||
return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET
|
||||
}
|
||||
|
||||
fun msToUs(timeMs: Long): Long {
|
||||
return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000
|
||||
}
|
||||
|
||||
private fun removeUnusedItemDataEntries(itemIds: IntArray) {
|
||||
val scratchItemIds = HashSet<Int>( /* initialCapacity= */itemIds.size * 2)
|
||||
for (id in itemIds) {
|
||||
scratchItemIds.add(id)
|
||||
}
|
||||
var index = 0
|
||||
while (index < itemIdToData.size()) {
|
||||
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
|
||||
itemIdToData.removeAt(index)
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
itemIdToData = SparseArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.source.TrackGroup
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||
import com.google.android.exoplayer2.util.Assertions
|
||||
|
||||
/**
|
||||
* [TrackSelection] that only selects the first track of the provided [TrackGroup].
|
||||
*
|
||||
*
|
||||
* This relies on [CastPlayer] track groups only having one track.
|
||||
*/
|
||||
/* package */
|
||||
internal class CastTrackSelection
|
||||
/** @param trackGroup The [TrackGroup] from which the first track will only be selected.
|
||||
*/(private val trackGroup: TrackGroup) : TrackSelection {
|
||||
override fun getType(): Int {
|
||||
return TrackSelection.TYPE_UNSET
|
||||
}
|
||||
|
||||
override fun getTrackGroup(): TrackGroup {
|
||||
return trackGroup
|
||||
}
|
||||
|
||||
override fun length(): Int {
|
||||
return 1
|
||||
}
|
||||
|
||||
override fun getFormat(index: Int): Format {
|
||||
Assertions.checkArgument(index == 0)
|
||||
return trackGroup.getFormat(0)
|
||||
}
|
||||
|
||||
override fun getIndexInTrackGroup(index: Int): Int {
|
||||
return if (index == 0) 0 else C.INDEX_UNSET
|
||||
}
|
||||
|
||||
override fun indexOf(format: Format): Int {
|
||||
return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET
|
||||
}
|
||||
|
||||
override fun indexOf(indexInTrackGroup: Int): Int {
|
||||
return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET
|
||||
}
|
||||
|
||||
// Object overrides.
|
||||
override fun hashCode(): Int {
|
||||
return System.identityHashCode(trackGroup)
|
||||
}
|
||||
|
||||
// Track groups are compared by identity not value, as distinct groups may have the same value.
|
||||
override fun equals(obj: Any?): Boolean {
|
||||
if (this === obj) {
|
||||
return true
|
||||
}
|
||||
if (obj == null || javaClass != obj.javaClass) {
|
||||
return false
|
||||
}
|
||||
val other = obj as CastTrackSelection
|
||||
return trackGroup === other.trackGroup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.*
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
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
|
||||
|
||||
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
|
||||
var tag = "MediaSessionCallback"
|
||||
|
||||
private var mediaButtonClickCount: Int = 0
|
||||
private var mediaButtonClickTimeout: Long = 1000 //ms
|
||||
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it,true, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
|
||||
playerNotificationService.play()
|
||||
}
|
||||
|
||||
override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||
super.onPrepareFromSearch(query, extras)
|
||||
}
|
||||
|
||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
|
||||
playerNotificationService.pause()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
playerNotificationService.pause()
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
playerNotificationService.skipToPrevious()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
playerNotificationService.skipToNext()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
playerNotificationService.seekPlayer(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?) {
|
||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
return handleCallMediaButton(mediaButtonEvent)
|
||||
}
|
||||
|
||||
private fun handleCallMediaButton(intent: Intent): Boolean {
|
||||
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||
|
||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||
val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (keyEvent?.action == KeyEvent.ACTION_UP) {
|
||||
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
||||
when (keyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||
Log.d(tag, "handleCallMediaButton: Headset Hook")
|
||||
if (0 == mediaButtonClickCount) {
|
||||
if (playerNotificationService.mPlayer.isPlaying)
|
||||
playerNotificationService.pause()
|
||||
else
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Pause")
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleMediaButtonClickCount() {
|
||||
mediaButtonClickCount++
|
||||
if (1 == mediaButtonClickCount) {
|
||||
Timer().schedule(mediaButtonClickTimeout) {
|
||||
mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount)
|
||||
mediaButtonClickCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak")
|
||||
object : Handler(){
|
||||
override fun handleMessage(msg: Message) {
|
||||
super.handleMessage(msg)
|
||||
if (2 == msg.what) {
|
||||
playerNotificationService.jumpBackward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
else if (msg.what >= 3) {
|
||||
playerNotificationService.jumpForward()
|
||||
playerNotificationService.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||
super.onCustomAction(action, extras)
|
||||
|
||||
when (action) {
|
||||
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
|
||||
class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer {
|
||||
var tag = "MediaSessionPlaybackPreparer"
|
||||
|
||||
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
|
||||
Log.d(tag, "ON COMMAND $command")
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getSupportedPrepareActions(): Long {
|
||||
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||
}
|
||||
|
||||
override fun onPrepare(playWhenReady: Boolean) {
|
||||
Log.d(tag, "ON PREPARE $playWhenReady")
|
||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||
|
||||
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
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
}
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM URI $uri")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
|
||||
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
|
||||
const val PLAYMETHOD_TRANSCODE = 2
|
||||
const val PLAYMETHOD_LOCAL = 3
|
||||
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
|
||||
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
|
||||
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
||||
const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
||||
const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.data.PlayerState
|
||||
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
|
||||
|
||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
||||
var tag = "PlayerListener"
|
||||
|
||||
companion object {
|
||||
var lastPauseTime: Long = 0 //ms
|
||||
var lazyIsPlaying: Boolean = false
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val errorMessage = error.message ?: "Unknown Error"
|
||||
Log.e(tag, "onPlayerError $errorMessage")
|
||||
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||
// If playing set seeking flag
|
||||
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK")
|
||||
playerNotificationService.mediaProgressSyncer.seek()
|
||||
lastPauseTime = 0 // When seeking while paused reset the auto-rewind
|
||||
} else {
|
||||
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}")
|
||||
|
||||
val player = playerNotificationService.currentPlayer
|
||||
|
||||
// Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek
|
||||
// When a seek occurs the player is paused and buffering, then plays again right afterwards.
|
||||
if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) {
|
||||
Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored")
|
||||
return
|
||||
}
|
||||
if (lazyIsPlaying == isPlaying) {
|
||||
Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
lazyIsPlaying = isPlaying
|
||||
|
||||
if (isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||
var seekBackTime = calcPauseSeekBackTime()
|
||||
if (seekBackTime > 0) {
|
||||
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTime()
|
||||
val newTime = currentTime - seekBackTime
|
||||
if (newTime < minSeekBackTime) {
|
||||
seekBackTime = currentTime - minSeekBackTime
|
||||
}
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if (seekBackTime > 0L) {
|
||||
playerNotificationService.seekBackward(seekBackTime)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}")
|
||||
lastPauseTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// 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) }
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
||||
|
||||
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
||||
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading)
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}")
|
||||
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) {
|
||||
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
|
||||
|
||||
if (lastPauseTime == 0L) {
|
||||
lastPauseTime = -1
|
||||
}
|
||||
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||
}
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) {
|
||||
Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition)
|
||||
playerNotificationService.sendClientMetadata(PlayerState.BUFFERING)
|
||||
}
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
||||
Log.d(tag, "STATE_ENDED")
|
||||
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
||||
|
||||
playerNotificationService.handlePlaybackEnded()
|
||||
}
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
||||
Log.d(tag, "STATE_IDLE")
|
||||
playerNotificationService.sendClientMetadata(PlayerState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
||||
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
||||
}
|
||||
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
||||
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcPauseSeekBackTime() : Long {
|
||||
if (lastPauseTime <= 0) return 0
|
||||
val time: Long = System.currentTimeMillis() - lastPauseTime
|
||||
val seekback: Long
|
||||
if (time < 10000) seekback = 0 // 10s or less = no seekback
|
||||
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
|
||||
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
|
||||
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
|
||||
else seekback = 29500 // 30m and up = jump back 30s
|
||||
return seekback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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) {
|
||||
|
||||
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(
|
||||
notificationId: Int,
|
||||
dismissedByUser: Boolean
|
||||
) {
|
||||
if (dismissedByUser) {
|
||||
Log.d(tag, "onNotificationCancelled dismissed by user")
|
||||
playerNotificationService.stopSelf()
|
||||
} 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) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.audiobookshelf.app
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import java.lang.Math.sqrt
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
@@ -35,7 +35,8 @@ class ShakeDetector : SensorEventListener {
|
||||
val gZ = z / SensorManager.GRAVITY_EARTH
|
||||
// gForce will be close to 1 when there is no movement.
|
||||
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
|
||||
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
|
||||
val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f
|
||||
if (gForce > shakeThreshold) {
|
||||
val now = System.currentTimeMillis()
|
||||
// ignore shake events too close to each other (500ms)
|
||||
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
|
||||
@@ -60,7 +61,7 @@ class ShakeDetector : SensorEventListener {
|
||||
* from the Google Play Store and run it to see how
|
||||
* many G's it takes to register a shake
|
||||
*/
|
||||
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
||||
// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
||||
private const val SHAKE_SLOP_TIME_MS = 500
|
||||
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
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.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.CastManager
|
||||
import com.audiobookshelf.app.player.PlayerListener
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import com.google.android.gms.cast.CastDevice
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
||||
class AbsAudioPlayer : Plugin() {
|
||||
private val tag = "AbsAudioPlayer"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler:ApiHandler
|
||||
var castManager:CastManager? = null
|
||||
|
||||
lateinit var playerNotificationService: PlayerNotificationService
|
||||
|
||||
private var isCastAvailable:Boolean = false
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
|
||||
val foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
|
||||
playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
|
||||
override fun onPlaybackSession(playbackSession: PlaybackSession) {
|
||||
notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession)))
|
||||
}
|
||||
|
||||
override fun onPlaybackClosed() {
|
||||
emit("onPlaybackClosed", true)
|
||||
}
|
||||
|
||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
||||
emit("onPlayingUpdate", isPlaying)
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||
}
|
||||
|
||||
override fun onSleepTimerEnded(currentPosition: Long) {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
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) {
|
||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
override fun onPlaybackFailed(errorMessage: String) {
|
||||
emit("onPlaybackFailed", errorMessage)
|
||||
}
|
||||
|
||||
override fun onMediaPlayerChanged(mediaPlayer:String) {
|
||||
emit("onMediaPlayerChanged", mediaPlayer)
|
||||
}
|
||||
|
||||
override fun onProgressSyncFailing() {
|
||||
emit("onProgressSyncFailing", "")
|
||||
}
|
||||
|
||||
override fun onProgressSyncSuccess() {
|
||||
emit("onProgressSyncSuccess", "")
|
||||
}
|
||||
|
||||
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
||||
emit("onNetworkMeteredChanged", isUnmetered)
|
||||
}
|
||||
|
||||
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
|
||||
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
|
||||
emit("onPlaybackSpeedChanged", playbackSpeed)
|
||||
}
|
||||
})
|
||||
|
||||
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
}
|
||||
|
||||
fun emit(evtName: String, value: Any) {
|
||||
val ret = JSObject()
|
||||
ret.put("value", value)
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
private fun initCastManager() {
|
||||
val googleApi = GoogleApiAvailability.getInstance()
|
||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||
|
||||
if (statusCode != ConnectionResult.SUCCESS) {
|
||||
if (statusCode == ConnectionResult.SERVICE_MISSING) {
|
||||
Log.w(tag, "initCastManager: Google Api Missing")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_DISABLED) {
|
||||
Log.w(tag, "initCastManager: Google Api Disabled")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_INVALID) {
|
||||
Log.w(tag, "initCastManager: Google Api Invalid")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_UPDATING) {
|
||||
Log.w(tag, "initCastManager: Google Api Updating")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) {
|
||||
Log.w(tag, "initCastManager: Google Api Update Required")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val connListener = object: CastManager.ChromecastListener() {
|
||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
|
||||
isCastAvailable = available
|
||||
emit("onCastAvailableUpdate", available)
|
||||
}
|
||||
|
||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "ChromecastListener: CAST onSessionRejoin")
|
||||
}
|
||||
|
||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "ChromecastListener: CAST onMediaLoaded")
|
||||
}
|
||||
|
||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "ChromecastListener: CAST onMediaUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "ChromecastListener: CAST onSessionUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "ChromecastListener: CAST onSessionEnd")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
||||
Log.d(tag, "ChromecastListener: CAST onMessageReceived")
|
||||
}
|
||||
}
|
||||
|
||||
castManager = CastManager(mainActivity)
|
||||
castManager?.startRouteScan(connListener)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun prepareLibraryItem(call: PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
val episodeId = call.getString("episodeId", "").toString()
|
||||
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")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
|
||||
}
|
||||
|
||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
||||
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
|
||||
var episode: PodcastEpisode? = null
|
||||
if (episodeId.isNotEmpty()) {
|
||||
val podcastMedia = it.media as Podcast
|
||||
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
|
||||
if (episode == null) {
|
||||
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
val playbackSession = it.getPlaybackSession(episode)
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
playbackSession.currentTime = startTimeOverride
|
||||
}
|
||||
|
||||
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||
playerNotificationService.mediaProgressSyncer.stop {
|
||||
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
||||
PlayerListener.lazyIsPlaying = false
|
||||
|
||||
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||
playerNotificationService.preparePlayer(
|
||||
playbackSession,
|
||||
playWhenReady,
|
||||
playbackRate
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.reset()
|
||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
return call.resolve(JSObject())
|
||||
}
|
||||
} else { // Play library item from server
|
||||
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.mediaProgressSyncer.stop {
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||
} else {
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
it.currentTime = startTimeOverride
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
|
||||
PlayerListener.lazyIsPlaying = false
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
val bufferedTime = playerNotificationService.getBufferedTimeSeconds()
|
||||
val ret = JSObject()
|
||||
ret.put("value", currentTime)
|
||||
ret.put("bufferedTime", bufferedTime)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun pausePlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.pause()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun playPlayer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.play()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun playPause(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val playing = playerNotificationService.playPause()
|
||||
call.resolve(JSObject("{\"playing\":$playing}"))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
Log.d(tag, "seek action to $time")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekForward(call: PluginCall) {
|
||||
val amount:Int = call.getInt("value", 0) ?: 0
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekForward(amount * 1000L) // convert to ms
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekBackward(call: PluginCall) {
|
||||
val amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekBackward(amount * 1000L) // convert to ms
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPlaybackSpeed(call: PluginCall) {
|
||||
val playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.setPlaybackSpeed(playbackSpeed)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun closePlayback(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.closePlayback()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setSleepTimer(call: PluginCall) {
|
||||
val time:Long = call.getString("time", "360000")!!.toLong()
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSleepTimerTime(call: PluginCall) {
|
||||
val time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", time)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun increaseSleepTime(call: PluginCall) {
|
||||
val time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun decreaseSleepTime(call: PluginCall) {
|
||||
val time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun requestSession(call: PluginCall) {
|
||||
// Need to make sure the player service has been started
|
||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||
call.resolve()
|
||||
if (castManager == null) {
|
||||
Log.e(tag, "Cast Manager not initialized")
|
||||
return
|
||||
}
|
||||
castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
|
||||
override fun onError(errorCode: Int) {
|
||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
|
||||
}
|
||||
|
||||
override fun onJoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getIsCastAvailable(call: PluginCall) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", isCastAvailable)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@CapacitorPlugin(name = "AbsDatabase")
|
||||
class AbsDatabase : Plugin() {
|
||||
val tag = "AbsDatabase"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var apiHandler: ApiHandler
|
||||
|
||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDeviceData(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val deviceData = DeviceManager.dbManager.getDeviceData()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalFolders(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val folders = DeviceManager.dbManager.getAllLocalFolders()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalFolder(call:PluginCall) {
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
||||
val folderObj = jacksonMapper.writeValueAsString(it)
|
||||
call.resolve(JSObject(folderObj))
|
||||
} ?: call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItem(call:PluginCall) {
|
||||
val id = call.getString("id", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemByLId(call:PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItems(call:PluginCall) {
|
||||
val mediaType = call.getString("mediaType", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
val userId = serverConfigPayload.userId
|
||||
val username = serverConfigPayload.username
|
||||
val token = serverConfigPayload.token
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
val serverAddress = call.getString("address", "").toString()
|
||||
|
||||
// Create new server connection config
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||
|
||||
// Add and save
|
||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
} else {
|
||||
var shouldSave = false
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
||||
serverConnectionConfig?.userId = userId
|
||||
serverConnectionConfig?.username = username
|
||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||
serverConnectionConfig?.token = token
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
// Set last connection config
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
}
|
||||
|
||||
DeviceManager.serverConnectionConfig = serverConnectionConfig
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig)))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeServerConnectionConfig(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
}
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) {
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun logout(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAllLocalMediaProgress(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalMediaProgressForServerItem(call:PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
var episodeId:String? = call.getString("episodeId", "").toString()
|
||||
if (episodeId == "") episodeId = null
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) }
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalMediaProgress(call:PluginCall) {
|
||||
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
apiHandler.syncLocalMediaProgressForUser {
|
||||
Log.d(tag, "Finished syncing local media progress for user")
|
||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
|
||||
if (savedSessions.isNotEmpty()) {
|
||||
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates received via web socket
|
||||
// This function doesn't need to sync with the server also because this data is coming from the server
|
||||
// If sending the localMediaProgressId then update existing media progress
|
||||
// If sending localLibraryItemId then save new local media progress
|
||||
@PluginMethod
|
||||
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
|
||||
val serverMediaProgress = call.getObject("mediaProgress").toString()
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
|
||||
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
|
||||
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
|
||||
|
||||
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
|
||||
|
||||
if (localMediaProgressId == "") {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem != null) {
|
||||
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
|
||||
val localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = mediaProgress.episodeId)
|
||||
|
||||
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
} else {
|
||||
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
|
||||
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
||||
call.resolve()
|
||||
} else {
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
|
||||
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
|
||||
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
|
||||
|
||||
val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val isFinished = call.getBoolean("isFinished", false) == true
|
||||
|
||||
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) { // Create new local media progress if does not exist
|
||||
Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
|
||||
if (localLibraryItem == null) {
|
||||
return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
}
|
||||
if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) {
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
}
|
||||
|
||||
val duration: Double
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
if (!localEpisodeId.isNullOrEmpty()) {
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { episode ->
|
||||
episode.id == localEpisodeId
|
||||
}
|
||||
if (podcastEpisode == null) {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
duration = podcastEpisode.duration ?: 0.0
|
||||
} else {
|
||||
val book = localLibraryItem.media as Book
|
||||
duration = book.duration ?: 0.0
|
||||
}
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = duration,
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = podcastEpisode?.serverEpisodeId)
|
||||
} else {
|
||||
localMediaProgress.updateIsFinished(isFinished)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
||||
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
|
||||
|
||||
// Send update to server media progress is linked to a server and user is logged into that server
|
||||
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
||||
if (DeviceManager.serverConnectionConfigId == configId) {
|
||||
val libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||
val episodeId = localMediaProgress.episodeId ?: ""
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("isFinished", isFinished)
|
||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", true)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", false)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
|
||||
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
||||
|
||||
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
||||
Log.d(tag, "updateLocalTrackOrder $tracks")
|
||||
|
||||
var index = 1
|
||||
var hasUpdates = false
|
||||
for (i in 0 until tracks.length()) {
|
||||
val track = tracks.getJSONObject(i)
|
||||
val localFileId = track.getString("localFileId")
|
||||
|
||||
val existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
||||
if (existingTrack != null) {
|
||||
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
|
||||
if (existingTrack.index != index) hasUpdates = true
|
||||
existingTrack.index = index++
|
||||
} else {
|
||||
Log.e(tag, "Audio track with local file id not found")
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Log.d(tag, "Save library item track orders")
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
} else {
|
||||
Log.d(tag, "No tracks need to be updated")
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
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) {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
||||
val mediaId = call.getString("mediaId") ?: ""
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId)
|
||||
if (mediaItemHistory == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsDownloader")
|
||||
class AbsDownloader : Plugin() {
|
||||
private val tag = "AbsDownloader"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var downloadManager: DownloadManager
|
||||
lateinit var apiHandler: ApiHandler
|
||||
lateinit var folderScanner: FolderScanner
|
||||
lateinit var downloadItemManager: DownloadItemManager
|
||||
|
||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem:DownloadItem) {
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
|
||||
}
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
|
||||
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
|
||||
}
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun downloadLibraryItem(call: PluginCall) {
|
||||
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()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
|
||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
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\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||
podcastEpisode.id == episodeId
|
||||
}
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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("/", "_")
|
||||
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
|
||||
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
|
||||
}
|
||||
|
||||
// Replace characters that cant be used in the file system
|
||||
// Reserved characters: ?:\"*|/\\<>
|
||||
private fun cleanStringForFileSystem(str:String):String {
|
||||
val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">")
|
||||
var newTitle = str
|
||||
newTitle = newTitle.replace(":", " -") // Special case replace : with -
|
||||
|
||||
reservedCharacters.forEach {
|
||||
newTitle = newTitle.replace(it, "")
|
||||
}
|
||||
return newTitle
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
if (libraryItem.mediaType == "book") {
|
||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||
|
||||
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 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())
|
||||
|
||||
// Create download item part for each audio track
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
||||
// Add cover download item
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
|
||||
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val audioTrack = episode?.audioTrack
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = 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 destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
|
||||
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
|
||||
|
||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.callback.FolderPickerCallback
|
||||
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
|
||||
|
||||
@CapacitorPlugin(name = "AbsFileSystem")
|
||||
class AbsFileSystem : Plugin() {
|
||||
private val TAG = "AbsFileSystem"
|
||||
private val tag = "AbsFileSystem"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun selectFolder(call: PluginCall) {
|
||||
val mediaType = call.data.getString("mediaType", "book").toString()
|
||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val simplePath = folder.getSimplePath(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||
|
||||
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
||||
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(
|
||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
||||
"\nWould you like to grant access to this folder?")
|
||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", "User Canceled, Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", "Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@PluginMethod
|
||||
fun requestStoragePermission(call: PluginCall) {
|
||||
Log.d(TAG, "Request Storage Permissions")
|
||||
mainActivity.storageHelper.requestStorageAccess()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkStoragePermission(call: PluginCall) {
|
||||
val res: Boolean
|
||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
res = SimpleStorage.hasStoragePermission(context)
|
||||
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
|
||||
} else {
|
||||
Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up")
|
||||
res = true
|
||||
}
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", res)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkFolderPermissions(call: PluginCall) {
|
||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
|
||||
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", hasAccess)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@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())
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeFolder(call: PluginCall) {
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalFolder(folderId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalLibraryItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
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()
|
||||
val absolutePath = call.data.getString("absolutePath", "").toString()
|
||||
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
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
call.resolve(JSObject("{\"success\":$success}"))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteTrackFromItem(call: PluginCall) {
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
||||
val contentUrl = call.data.getString("trackContentUrl", "").toString()
|
||||
Log.d(tag, "deleteTrackFromItem $contentUrl")
|
||||
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
|
||||
return call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
if (success) {
|
||||
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
|
||||
localLibraryItem.removeLocalFile(trackLocalFileId)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
} else {
|
||||
call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.content.Context
|
||||
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.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ApiHandler(var ctx:Context) {
|
||||
val tag = "ApiHandler"
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
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")
|
||||
e.printStackTrace()
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Failed to connect")
|
||||
cb(jsobj)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
cb(jsobj)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
if (bodyString == "OK") {
|
||||
cb(JSObject())
|
||||
} else {
|
||||
try {
|
||||
var jsonObj = JSObject()
|
||||
if (bodyString.startsWith("[")) {
|
||||
val array = JSArray(bodyString)
|
||||
jsonObj.put("value", array)
|
||||
} else {
|
||||
jsonObj = JSObject(bodyString)
|
||||
}
|
||||
cb(jsonObj)
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Invalid response body")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
val libraries = mutableListOf<Library>()
|
||||
|
||||
var array = JSONArray()
|
||||
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
|
||||
array = it.getJSONArray("libraries")
|
||||
} else if (it.has("value")) {
|
||||
array = it.getJSONArray("value")
|
||||
}
|
||||
|
||||
for (i in 0 until array.length()) {
|
||||
libraries.add(mapper.readValue(array.get(i).toString()))
|
||||
}
|
||||
cb(libraries)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
|
||||
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
|
||||
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
|
||||
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
|
||||
getRequest(requestUrl, null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
getRequest("/api/libraries/$libraryId/items?limit=100&minified=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())
|
||||
items.add(item)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
|
||||
getRequest("/api/me/items-in-progress", null, null) {
|
||||
val items = mutableListOf<ItemInProgress>()
|
||||
if (it.has("libraryItems")) {
|
||||
val array = it.getJSONArray("libraryItems")
|
||||
for (i in 0 until array.length()) {
|
||||
val jsobj = array.get(i) as JSONObject
|
||||
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||
items.add(itemInProgress)
|
||||
}
|
||||
}
|
||||
cb(items)
|
||||
}
|
||||
}
|
||||
|
||||
fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||
|
||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||
postRequest(endpoint, payload, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
||||
it.put("serverAddress", DeviceManager.serverAddress)
|
||||
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
||||
cb(playbackSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||
|
||||
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||
|
||||
postRequest("/api/session/local", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "updateMediaProgress patched progress")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
|
||||
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
|
||||
getRequest(endpoint, pingClient, serverConnectionConfig) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, "getMediaProgress: Failed to get progress")
|
||||
cb(null)
|
||||
} else {
|
||||
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
|
||||
cb(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
|
||||
val endpoint = "/api/session/$playbackSessionId"
|
||||
getRequest(endpoint, null, null) {
|
||||
val err = it.getString("error")
|
||||
if (!err.isNullOrEmpty()) {
|
||||
cb(null)
|
||||
} else {
|
||||
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "pingServer: Pinging ${config.address}")
|
||||
getRequest("/ping", pingClient, config) {
|
||||
val success = it.getString("success")
|
||||
if (success.isNullOrEmpty()) {
|
||||
Log.d(tag, "pingServer: Ping ${config.address} Failed")
|
||||
cb(false)
|
||||
} else {
|
||||
Log.d(tag, "pingServer: Ping ${config.address} Successful")
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||
postRequest("/api/authorize", JSObject(), config) {
|
||||
val error = it.getString("error")
|
||||
if (!error.isNullOrEmpty()) {
|
||||
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
|
||||
cb(null)
|
||||
} else {
|
||||
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
|
||||
val user = it.getJSObject("user")
|
||||
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
|
||||
for (i in 0 until mediaProgress.length()) {
|
||||
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
|
||||
mediaProgressList.add(mediaProg)
|
||||
}
|
||||
Log.d(tag, "authorize: Authorize ${config.address} Successful")
|
||||
cb(mediaProgressList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
response.results.forEach { localSessionSyncResult ->
|
||||
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)
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,5 @@
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
||||
</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,-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>
|
||||