mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Compare commits
603 commits
Merge-with
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d3ab40093 |
||
|
|
c4ccc5d351 |
||
|
|
fcac19737c |
||
|
|
77dc9f7484 |
||
|
|
f6a65f38db |
||
|
|
4d9a080341 |
||
|
|
7936ccf5d3 |
||
|
|
15b5013e28 | ||
|
|
6f522828a4 | ||
|
|
ad727b96cf |
||
|
|
67e278b2b7 | ||
|
|
7f1cba99e4 | ||
|
|
ff29fe6ee6 | ||
|
|
aac2311722 |
||
|
|
60e3c48aca |
||
|
|
14dd418652 |
||
|
|
5012821216 |
||
|
|
ab379ab31c |
||
|
|
8fcb3e3121 |
||
|
|
30adb1cd9d |
||
|
|
63e27c2ea5 |
||
|
|
b2f08847e1 |
||
|
|
150ad5fc9f |
||
|
|
82f8ab489e |
||
|
|
04dda008c4 |
||
|
|
0aa48f335a |
||
|
|
a28ee41368 |
||
|
|
2fc279f4ae |
||
|
|
15d2d21631 |
||
|
|
e3ff1cf455 |
||
|
|
dfd127265a |
||
|
|
c8a863e332 |
||
|
|
0c418fdf9b |
||
|
|
4c7379c766 |
||
|
|
bb8144a52e |
||
|
|
073af50f5f |
||
|
|
63465ed7a9 |
||
|
|
12de924559 |
||
|
|
627dd45309 |
||
|
|
a157115cfa |
||
|
|
694193fa3e |
||
|
|
febb843424 |
||
|
|
8be8e54746 |
||
|
|
e86c926c30 |
||
|
|
145c42f1c8 |
||
|
|
9b1ac5fc28 |
||
|
|
699a6979a5 |
||
|
|
e1d4a46309 |
||
|
|
c1b5f5c128 |
||
|
|
e5c9e96c83 |
||
|
|
02b956940a |
||
|
|
03b8b6e637 |
||
|
|
29ec554334 |
||
|
|
5f64e40a7e |
||
|
|
d17111c1c1 |
||
|
|
a5582a7a67 |
||
|
|
1a05651510 |
||
|
|
ad27eb3b0e |
||
|
|
6b93af5803 |
||
|
|
55a0eb66cb |
||
|
|
b776642775 | ||
|
|
09fe9873cf |
||
|
|
0d40b5ebe3 |
||
|
|
9ca1d02bdc |
||
|
|
b06d9f224d |
||
|
|
b9746c2b17 |
||
|
|
c71d5d8add |
||
|
|
afa178a63a |
||
|
|
b702b7b1ec |
||
|
|
bda6673cfd |
||
|
|
7a0cd07dc1 |
||
|
|
30d223cfe3 |
||
|
|
4c061edd7c |
||
|
|
4c95610238 |
||
|
|
3345326cb2 |
||
|
|
607a4510b6 |
||
|
|
f775c1725d |
||
|
|
7eec0eff02 |
||
|
|
358a20eb77 |
||
|
|
0391a3b89c |
||
|
|
9bebfe4590 |
||
|
|
b3e3dadc72 |
||
|
|
b87fdfbf85 |
||
|
|
dff56026de |
||
|
|
5502e478c4 |
||
|
|
960f8449b7 |
||
|
|
d0852449a5 |
||
|
|
e697bf7554 |
||
|
|
db2bf5e7be |
||
|
|
469a71236b |
||
|
|
4d5cd288ab |
||
|
|
af828de8d5 |
||
|
|
ee4d1dedc5 |
||
|
|
f1cc4db89c |
||
|
|
3874cb9f9d |
||
|
|
0a5399d9b6 |
||
|
|
71bd48f493 |
||
|
|
83c473d9f8 |
||
|
|
c28a3cb987 |
||
|
|
d3828eeafe |
||
|
|
c07e6d3222 |
||
|
|
949b5830b6 |
||
|
|
ff1ffbeb83 |
||
|
|
138e1a1f0e |
||
|
|
004c481a5e |
||
|
|
e2946cad6b |
||
|
|
e6b9d621f9 |
||
|
|
0019f85501 |
||
|
|
0744189020 |
||
|
|
4399a612df |
||
|
|
e01ff4d843 |
||
|
|
6cef9f7ea2 |
||
|
|
9a18ef6411 |
||
|
|
6df3ef14f6 |
||
|
|
5db541d7cc |
||
|
|
aa8972870c |
||
|
|
afdc4988ac |
||
|
|
e6c111532d |
||
|
|
ffa7b0248a | ||
|
|
c13d290377 |
||
|
|
1bf7e14eab |
||
|
|
145ceea50f |
||
|
|
2fad760426 |
||
|
|
ff0dea3fbb |
||
|
|
44e5b86176 |
||
|
|
d8f89df163 |
||
|
|
a74563d003 |
||
|
|
0a24661e4c |
||
|
|
ed2bdf44fb |
||
|
|
51d91bf9a7 |
||
|
|
fb89fd60b8 |
||
|
|
7db7742c73 | ||
|
|
b246d80861 |
||
|
|
d321aba3a7 |
||
|
|
22937424fa |
||
|
|
7f0034e872 |
||
|
|
35e38a53ad |
||
|
|
6d8a31809d |
||
|
|
650c7583af |
||
|
|
34af3a4b2f |
||
|
|
9ef1f1cc41 |
||
|
|
6ede44d85f |
||
|
|
2f03ca7de9 |
||
|
|
7ce2dfc4aa |
||
|
|
16510923d2 |
||
|
|
a9c2c0644a |
||
|
|
4468ce3d80 |
||
|
|
faeb71da2c |
||
|
|
86bc0b8345 |
||
|
|
1ff0b5dccd |
||
|
|
a2e63174be | ||
|
|
eb60be54ed | ||
|
|
8d5b73495d | ||
|
|
a3bb853691 |
||
|
|
375b3ec46e |
||
|
|
ad67b9ddab | ||
|
|
638cc4fee9 |
||
|
|
4817b29b9c |
||
|
|
040ac77b1a |
||
|
|
527046766a |
||
|
|
adc653943b |
||
|
|
81df68e137 |
||
|
|
a01bb9e55b |
||
|
|
807bd85fa9 |
||
|
|
510d11f705 |
||
|
|
bd69054f5d |
||
|
|
694e7abbdf |
||
|
|
e3f9f255c7 |
||
|
|
21b341e12f |
||
|
|
e3999d6e9a |
||
|
|
f0f4ec87bc |
||
|
|
809a38507b |
||
|
|
1a380a3239 |
||
|
|
93d81ea038 |
||
|
|
e007714701 |
||
|
|
805f80b2ac |
||
|
|
b5fb0997c4 |
||
|
|
ca918b1581 |
||
|
|
09779b4ee0 |
||
|
|
012d38398e |
||
|
|
d1db4c3370 |
||
|
|
8d318ca84a |
||
|
|
eea6e13346 |
||
|
|
2b7d102716 |
||
|
|
9ea7674a0f |
||
|
|
3dcf7076d0 |
||
|
|
8b14fcb881 |
||
|
|
01f21e0fe8 |
||
|
|
bdef6524e7 |
||
|
|
f40a8d9418 |
||
|
|
03fcb106ac |
||
|
|
636e157c63 |
||
|
|
5af1b80cb7 |
||
|
|
5dfc08aabb |
||
|
|
1676094488 |
||
|
|
19145c6cc4 |
||
|
|
ebb72d6a0c |
||
|
|
399b28c75b |
||
|
|
601483e103 |
||
|
|
9733d0b316 | ||
|
|
0cf199248a |
||
|
|
2624947b5b |
||
|
|
31c783d0b4 |
||
|
|
9f1b172f34 |
||
|
|
93dce8682e |
||
|
|
723c653b07 |
||
|
|
0c73f5e59a |
||
|
|
0eb152c5db |
||
|
|
8c5ab86714 |
||
|
|
85a769a898 |
||
|
|
96aa56209b |
||
|
|
d71d3890b5 |
||
|
|
19b1a40cf8 |
||
|
|
e5f483b0b2 |
||
|
|
6f1e0bef80 |
||
|
|
5e6272be3f | ||
|
|
97ec98b9e2 |
||
|
|
42fd0b5c76 |
||
|
|
42774f6183 |
||
|
|
f687508521 |
||
|
|
dbba6d7f27 | ||
|
|
f4da170a57 | ||
|
|
2a1876f54c |
||
|
|
f1d0a8e955 |
||
|
|
1c6be2d5cb |
||
|
|
fc802cdcdd |
||
|
|
2cfdab5498 |
||
|
|
4c2ee28d5a | ||
|
|
657f2fbcb2 |
||
|
|
b5ac668493 |
||
|
|
9d3b2ba3d2 |
||
|
|
5f51a8f7bc |
||
|
|
e886fde8b8 | ||
|
|
1356a954f3 |
||
|
|
3d90af29eb |
||
|
|
2a4ce89452 | ||
|
|
0543f1ffae |
||
|
|
a5f7920bca |
||
|
|
e8fe2944bb |
||
|
|
db91552f39 |
||
|
|
484c21cc1c |
||
|
|
ff9144ef54 |
||
|
|
10a477c2bd |
||
|
|
6d51c59b18 |
||
|
|
f98ce0558d |
||
|
|
f5e6d98cb0 |
||
|
|
91dc83e6a3 |
||
|
|
fe30a85a1c |
||
|
|
6e5a52e440 |
||
|
|
410cedc128 |
||
|
|
3c152e04d1 |
||
|
|
d0aed5e51a |
||
|
|
530619c8d0 |
||
|
|
3ef8f3030c |
||
|
|
2d87983eca |
||
|
|
6f3a8c1cd2 |
||
|
|
dfd6ce7651 | ||
|
|
88ad64b3b0 |
||
|
|
cebdbd2199 |
||
|
|
25b042fb83 |
||
|
|
fac0ef4c25 |
||
|
|
f7bc83024a |
||
|
|
5b170c0573 |
||
|
|
38cc121755 |
||
|
|
951b2110ad |
||
|
|
d4aefc4e64 |
||
|
|
c324eaf543 |
||
|
|
962ff1c058 | ||
|
|
7165b57268 |
||
|
|
e80dc63381 |
||
|
|
fa7ebc05b3 |
||
|
|
df0122c146 |
||
|
|
b49368100b |
||
|
|
0077cebaa6 |
||
|
|
a2085202ec |
||
|
|
765071ebef |
||
|
|
e11d36aed8 |
||
|
|
5bf2b4ead2 |
||
|
|
de61501b22 |
||
|
|
685884e67f | ||
|
|
6db295a799 |
||
|
|
2b60e3a893 |
||
|
|
3adf036135 |
||
|
|
c4aab5e5a8 |
||
|
|
7e2908c0bb |
||
|
|
22a0c25d83 |
||
|
|
11136fe63d |
||
|
|
a6786aaf98 |
||
|
|
5b0cbbf09f |
||
|
|
6a8c251013 |
||
|
|
908f83c50e |
||
|
|
6f40d2750f | ||
|
|
199f5b3a9d |
||
|
|
6ce9f29331 |
||
|
|
8b73c35e43 |
||
|
|
65313b4579 |
||
|
|
a8fdf5e8f2 |
||
|
|
87c5aada8f |
||
|
|
f0e429436f |
||
|
|
137d833d4a |
||
|
|
b2e0b7dec8 |
||
|
|
d542febcda |
||
|
|
f0ebfa47c8 |
||
|
|
51a877f405 |
||
|
|
4b93524e57 |
||
|
|
ef36bccc90 |
||
|
|
968bd59188 |
||
|
|
e4ba852007 |
||
|
|
504258bf15 |
||
|
|
48053164dc |
||
|
|
2a4468eb44 |
||
|
|
5153a74d4f |
||
|
|
138dea88c4 |
||
|
|
eb58cb1184 |
||
|
|
c9bffef7cb |
||
|
|
a7a6f2282a |
||
|
|
8ed7418fe4 | ||
|
|
3cb2196e62 |
||
|
|
7e9d1ded7f |
||
|
|
b7322ffb19 |
||
|
|
fd1620f3d7 |
||
|
|
fc8c0e809d |
||
|
|
1ccd3d732d |
||
|
|
bb6a17e23c | ||
|
|
2f2bbd7d88 | ||
|
|
749d131099 |
||
|
|
de6dfec452 |
||
|
|
b4da93c1de | ||
|
|
dd45ac9e8a | ||
|
|
b47209483a | ||
|
|
91b195241e |
||
|
|
abbad1bc94 |
||
|
|
b120a7bce2 |
||
|
|
5b4fd8d77d |
||
|
|
d277d8a9aa | ||
|
|
33eb3a3b29 | ||
|
|
f14557fe6a | ||
|
|
77294dc68e | ||
|
|
0a327ccbda |
||
|
|
177b1e47f3 | ||
|
|
3f5119525c |
||
|
|
b5d4c3bd27 |
||
|
|
cc00e73e16 |
||
|
|
462073bd74 |
||
|
|
08060314ad | ||
|
|
1d90858f64 |
||
|
|
bd05a67f26 | ||
|
|
bb8cbb5167 |
||
|
|
16c2290090 |
||
|
|
194678c419 |
||
|
|
0351053d80 |
||
|
|
74060e7da3 |
||
|
|
1e2a11d6e4 |
||
|
|
b8917ffa39 |
||
|
|
d4fff7cee6 |
||
|
|
527a6388a9 | ||
|
|
15333123cd |
||
|
|
2ae5b6cefb | ||
|
|
0d2a19b350 | ||
|
|
bff9727f96 | ||
|
|
a82059cb57 | ||
|
|
627c1bb223 |
||
|
|
24977a8d62 |
||
|
|
6957a8f95d | ||
|
|
2bed79b1f1 |
||
|
|
a5450e5da2 |
||
|
|
8fe34d3d2a | ||
|
|
7d6ba8c7a4 | ||
|
|
2baa75496e |
||
|
|
01e7acdeac |
||
|
|
10bc688eaf | ||
|
|
7f7c81828a | ||
|
|
f6b0ea8dfa |
||
|
|
0afbc90cd2 | ||
|
|
85c4c74222 | ||
|
|
b6e99d7358 | ||
|
|
130cc16e25 |
||
|
|
1629db2fc9 | ||
|
|
f05c65cf5c |
||
|
|
4ddd78ebb6 |
||
|
|
49731cd699 | ||
|
|
3fe247fb19 | ||
|
|
0839775172 |
||
|
|
6211b02e85 | ||
|
|
9c991f2abd |
||
|
|
6089cbc484 | ||
|
|
ce1f48978b | ||
|
|
f01820059b | ||
|
|
7d3b8c464e | ||
|
|
8193e39b30 | ||
|
|
557003895b | ||
|
|
d0c03321b9 |
||
|
|
2d82480398 |
||
|
|
b38a9b1ff5 | ||
|
|
1a4cbcaea0 | ||
|
|
9b4701fe91 | ||
|
|
c92ac3e8b3 | ||
|
|
39ff6ef8ef | ||
|
|
460b1be525 | ||
|
|
9a1358e295 |
||
|
|
823ffd8708 | ||
|
|
5bad6aca35 | ||
|
|
e2502de02c | ||
|
|
bac2ee9805 | ||
|
|
d436171a2f | ||
|
|
3ea6b1a8d5 | ||
|
|
afcbdeecc8 | ||
|
|
4e28e5f8cc | ||
|
|
1901eb371e | ||
|
|
c4852ce440 | ||
|
|
a3009af4f5 |
||
|
|
6948bf8073 | ||
|
|
61ca0a56be |
||
|
|
98b6417140 | ||
|
|
10c1ea2f02 | ||
|
|
b3abf1e45f | ||
|
|
f571596bbc |
||
|
|
e20e3dcfd3 | ||
|
|
35e1b8b4dc | ||
|
|
a05616e3e8 | ||
|
|
56cb3d7181 | ||
|
|
e95dc1db2a |
||
|
|
8f6e8a8e99 | ||
|
|
61d63b17d8 |
||
|
|
590c74111c | ||
|
|
c2b951a078 | ||
|
|
cbaca158fa | ||
|
|
20da3807a2 | ||
|
|
d247640dcf |
||
|
|
d536dffaf5 |
||
|
|
4e01d327c6 |
||
|
|
4d98690adb | ||
|
|
74867bed1c |
||
|
|
0eb241e6cb | ||
|
|
3ab9e11350 | ||
|
|
d2d2e41fb3 |
||
|
|
dd4f4a2b78 | ||
|
|
e43b4808d1 | ||
|
|
3ac462ae96 | ||
|
|
ecd529f73b |
||
|
|
2d65aefc76 | ||
|
|
3af0bf750c | ||
|
|
72871c18b5 |
||
|
|
44a2146c12 |
||
|
|
bbbb7c4982 |
||
|
|
ca6700e28d |
||
|
|
5103ad09dc | ||
|
|
f5c4864a3c | ||
|
|
653982a6bd | ||
|
|
22c0022684 |
||
|
|
7e6a28bb99 | ||
|
|
c5f6f36fc7 |
||
|
|
3137a68552 |
||
|
|
2475088f76 | ||
|
|
87d85429f8 |
||
|
|
32e243ce94 |
||
|
|
180987e2d0 | ||
|
|
b06f098447 | ||
|
|
6ff4f4c1ce | ||
|
|
0afc9f15d2 |
||
|
|
827cbbb0b5 |
||
|
|
a8ed8773de |
||
|
|
363ffa26de |
||
|
|
7c60ccdef2 | ||
|
|
d5316bff9b | ||
|
|
8dae4c2b0f |
||
|
|
6b87fb7831 | ||
|
|
6c325cf721 |
||
|
|
04ef6043b0 | ||
|
|
661dfc0927 | ||
|
|
4b4e006f4a |
||
|
|
3bdbb35754 | ||
|
|
c987f7581e | ||
|
|
c98f35fd94 | ||
|
|
a1824c86a3 | ||
|
|
bfb3313137 |
||
|
|
31da089eb1 | ||
|
|
3e4a5bdf4c | ||
|
|
446f774fb4 |
||
|
|
51a6e917b5 | ||
|
|
35084389a1 | ||
|
|
5aa9019d6d | ||
|
|
da6577e587 | ||
|
|
9755bbacb9 | ||
|
|
3ae44d5675 | ||
|
|
483ce2854f | ||
|
|
21e5a1e244 | ||
|
|
ed0d374721 | ||
|
|
4fcf396591 | ||
|
|
03d50a943a | ||
|
|
d5c42f7d5a | ||
|
|
4f28aef8f2 | ||
|
|
f30506a394 | ||
|
|
4d6e64adb6 | ||
|
|
afadf121f4 | ||
|
|
a2a4da5a29 | ||
|
|
6bc5d86ff9 | ||
|
|
f209c7286e | ||
|
|
c946115900 | ||
|
|
04f52f4a6d | ||
|
|
273a947f8e | ||
|
|
647e91bc4b | ||
|
|
c3296f3210 |
||
|
|
166a21f74e | ||
|
|
05a0d3cd81 | ||
|
|
927453d9fe |
||
|
|
9237817bd3 | ||
|
|
525bf8d861 | ||
|
|
847957362f |
||
|
|
51c1089162 |
||
|
|
da0be63b7c |
||
|
|
a95fcfc9db |
||
|
|
40a963588f |
||
|
|
906f1fdc9a |
||
|
|
b5566af401 |
||
|
|
0d431fd508 |
||
|
|
b115817357 | ||
|
|
c0a8461b87 | ||
|
|
8c9d52bc0e |
||
|
|
0f00b1baf0 | ||
|
|
ae1aaa3d7d | ||
|
|
b37aa55343 |
||
|
|
77d4ecd7c6 |
||
|
|
3b21ec3794 |
||
|
|
386ce75df1 | ||
|
|
27155e0f7e | ||
|
|
3121b5b123 | ||
|
|
8a5ddcd126 | ||
|
|
42bf8ed08e |
||
|
|
fb3576ea52 |
||
|
|
56a680fa9c | ||
|
|
633aef8783 | ||
|
|
a12d234ef4 | ||
|
|
bdb45b69d3 | ||
|
|
4449347593 | ||
|
|
b356ad9e61 |
||
|
|
94e7eb8e9d |
||
|
|
4f9016713f |
||
|
|
4ed65f8e07 | ||
|
|
7317278f57 | ||
|
|
53293dadd0 | ||
|
|
67b0549fd2 |
||
|
|
52d495f425 |
||
|
|
0cbee70683 |
||
|
|
4235c826a5 | ||
|
|
5245eff6e1 |
||
|
|
9c40abc4d3 | ||
|
|
019399952f | ||
|
|
cc99899cf1 | ||
|
|
8fff809b79 | ||
|
|
67318a62a3 | ||
|
|
288c5ffa39 | ||
|
|
8ebf5185a3 | ||
|
|
7bfcf25df4 | ||
|
|
2d7126d71f | ||
|
|
40a4f319b6 | ||
|
|
19dc1a2456 | ||
|
|
ac1012bcb8 |
||
|
|
ec3950ed4f | ||
|
|
3e2b0f2a17 | ||
|
|
29174dbb30 |
||
|
|
7b47f93190 | ||
|
|
13ee8e21d0 | ||
|
|
3a5d872545 | ||
|
|
fab55d82c4 | ||
|
|
8b2881f5f6 |
||
|
|
37244ab0f7 |
||
|
|
e85b31c35d | ||
|
|
1eaa4620dc | ||
|
|
76545f55c3 | ||
|
|
f0515c4dc9 |
||
|
|
ab324b93e8 | ||
|
|
d6df24eff2 |
||
|
|
e5834d485b | ||
|
|
6524eb220b |
||
|
|
2926dc6c8e |
||
|
|
f722785a37 |
||
|
|
aeab423d29 | ||
|
|
1da6a92569 | ||
|
|
b2fa765a2d |
||
|
|
bec0a2e7b9 |
||
|
|
51137701f2 |
||
|
|
5f12d067f9 |
||
|
|
00a91ca5fb | ||
|
|
33aecfbba5 |
||
|
|
0185854682 | ||
|
|
b4065b69be | ||
|
|
b6ac155350 |
||
|
|
aacd57cb5d | ||
|
|
3dd0fc6c8e | ||
|
|
135f63afff | ||
|
|
789cd14ef6 | ||
|
|
9d0cce47a6 | ||
|
|
4a8ee55018 | ||
|
|
df6c395acb |
||
|
|
1117271a71 |
||
|
|
7b11b9b585 |
||
|
|
5c20b479e5 | ||
|
|
0d2613d183 | ||
|
|
dd38556102 |
||
|
|
84493b7f3b | ||
|
|
4596afee06 | ||
|
|
3e2c2a5c86 |
723 changed files with 50273 additions and 17548 deletions
4
.github/ISSUE_TEMPLATE/application-bug.yml
vendored
4
.github/ISSUE_TEMPLATE/application-bug.yml
vendored
|
|
@ -80,13 +80,13 @@ body:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Your issue will be closed if you haven't done these steps.
|
description: Your issue will be closed if you haven't done these steps.
|
||||||
options:
|
options:
|
||||||
|
- label: I am sure my issue is related to the app and **NOT some extension**.
|
||||||
|
required: true
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
- label: I have written a short but informative title.
|
- label: I have written a short but informative title.
|
||||||
required: true
|
required: true
|
||||||
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
|
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
|
||||||
required: true
|
required: true
|
||||||
- label: If related to a provider, I have checked the site and it works, but not the app.
|
|
||||||
required: true
|
|
||||||
- label: I will fill out all of the requested information in this form.
|
- label: I will fill out all of the requested information in this form.
|
||||||
required: true
|
required: true
|
||||||
|
|
|
||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -2,7 +2,7 @@ blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Request a new provider or report bug with an existing provider
|
- name: Request a new provider or report bug with an existing provider
|
||||||
url: https://github.com/recloudstream
|
url: https://github.com/recloudstream
|
||||||
about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
|
about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
|
||||||
- name: Discord
|
- name: Discord
|
||||||
url: https://discord.gg/5Hus6fM
|
url: https://discord.gg/5Hus6fM
|
||||||
about: Join our discord for faster support on smaller issues.
|
about: Join our discord for faster support on smaller issues.
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
6
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
|
|
@ -27,9 +27,7 @@ body:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Your issue will be closed if you haven't done these steps.
|
description: Your issue will be closed if you haven't done these steps.
|
||||||
options:
|
options:
|
||||||
|
- label: My suggestion is **NOT** about adding a new provider
|
||||||
|
required: true
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
required: true
|
required: true
|
||||||
- label: I have written a short but informative title.
|
|
||||||
required: true
|
|
||||||
- label: I will fill out all of the requested information in this form.
|
|
||||||
required: true
|
|
||||||
|
|
|
||||||
BIN
.github/downloads.jpg
vendored
BIN
.github/downloads.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
BIN
.github/home.jpg
vendored
BIN
.github/home.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
21
.github/locales.py
vendored
21
.github/locales.py
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
import re
|
import re
|
||||||
import glob
|
import glob
|
||||||
import requests
|
import requests
|
||||||
|
import os
|
||||||
|
import lxml.etree as ET # builtin library doesn't preserve comments
|
||||||
|
|
||||||
|
|
||||||
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
|
||||||
|
|
@ -46,3 +48,22 @@ open(SETTINGS_PATH, "w+",encoding='utf-8').write(
|
||||||
END_MARKER +
|
END_MARKER +
|
||||||
after_src
|
after_src
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Go through each values.xml file and fix escaped \@string
|
||||||
|
for file in glob.glob(f"{XML_NAME}*/strings.xml"):
|
||||||
|
try:
|
||||||
|
tree = ET.parse(file)
|
||||||
|
for child in tree.getroot():
|
||||||
|
if not child.text:
|
||||||
|
continue
|
||||||
|
if child.text.startswith("\\@string/"):
|
||||||
|
print(f"[{file}] fixing {child.attrib['name']}")
|
||||||
|
child.text = child.text.replace("\\@string/", "@string/")
|
||||||
|
with open(file, 'wb') as fp:
|
||||||
|
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
|
||||||
|
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
|
||||||
|
# Remove trailing new line to be consistent with weblate
|
||||||
|
fp.seek(-1, os.SEEK_END)
|
||||||
|
fp.truncate()
|
||||||
|
except ET.ParseError as ex:
|
||||||
|
print(f"[{file}] {ex}")
|
||||||
|
|
|
||||||
BIN
.github/player.jpg
vendored
BIN
.github/player.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
BIN
.github/results.jpg
vendored
BIN
.github/results.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
BIN
.github/search.jpg
vendored
BIN
.github/search.jpg
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 149 KiB |
16
.github/workflows/build_to_archive.yml
vendored
16
.github/workflows/build_to_archive.yml
vendored
|
|
@ -19,23 +19,23 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate access token
|
- name: Generate access token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
- name: Generate access token (archive)
|
- name: Generate access token (archive)
|
||||||
id: generate_archive_token
|
id: generate_archive_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '11'
|
java-version: '17'
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
@ -56,7 +56,9 @@ jobs:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
- uses: actions/checkout@v3
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
repository: "recloudstream/cloudstream-archive"
|
repository: "recloudstream/cloudstream-archive"
|
||||||
token: ${{ steps.generate_archive_token.outputs.token }}
|
token: ${{ steps.generate_archive_token.outputs.token }}
|
||||||
|
|
|
||||||
11
.github/workflows/generate_dokka.yml
vendored
11
.github/workflows/generate_dokka.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate access token
|
- name: Generate access token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
|
|
@ -42,13 +42,14 @@ jobs:
|
||||||
cd $GITHUB_WORKSPACE/dokka/
|
cd $GITHUB_WORKSPACE/dokka/
|
||||||
rm -rf "./-cloudstream"
|
rm -rf "./-cloudstream"
|
||||||
|
|
||||||
- name: Setup JDK 11
|
- name: Setup JDK 17
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: 11
|
java-version: 17
|
||||||
|
distribution: 'adopt'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v2
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
- name: Generate Dokka
|
- name: Generate Dokka
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
8
.github/workflows/issue_action.yml
vendored
8
.github/workflows/issue_action.yml
vendored
|
|
@ -10,7 +10,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate access token
|
- name: Generate access token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
comment-body: '${index}. ${similarity} #${number}'
|
comment-body: '${index}. ${similarity} #${number}'
|
||||||
- name: Label if possible duplicate
|
- name: Label if possible duplicate
|
||||||
if: steps.similarity.outputs.similar-issues-found =='true'
|
if: steps.similarity.outputs.similar-issues-found =='true'
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate_token.outputs.token }}
|
github-token: ${{ steps.generate_token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
@ -37,7 +37,7 @@ jobs:
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
labels: ["possible duplicate"]
|
labels: ["possible duplicate"]
|
||||||
})
|
})
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Automatically close issues that dont follow the issue template
|
- name: Automatically close issues that dont follow the issue template
|
||||||
uses: lucasbento/auto-close-issues@v1.0.2
|
uses: lucasbento/auto-close-issues@v1.0.2
|
||||||
with:
|
with:
|
||||||
|
|
@ -68,7 +68,7 @@ jobs:
|
||||||
Found provider name: `${{ steps.provider_check.outputs.name }}`
|
Found provider name: `${{ steps.provider_check.outputs.name }}`
|
||||||
- name: Label if mentions provider
|
- name: Label if mentions provider
|
||||||
if: steps.provider_check.outputs.name != 'none'
|
if: steps.provider_check.outputs.name != 'none'
|
||||||
uses: actions/github-script@v6
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate_token.outputs.token }}
|
github-token: ${{ steps.generate_token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
|
||||||
15
.github/workflows/prerelease.yml
vendored
15
.github/workflows/prerelease.yml
vendored
|
|
@ -18,16 +18,16 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate access token
|
- name: Generate access token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/secrets"
|
repository: "recloudstream/secrets"
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '11'
|
java-version: '17'
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
|
|
@ -43,11 +43,14 @@ jobs:
|
||||||
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: |
|
run: |
|
||||||
./gradlew assemblePrerelease makeJar androidSourcesJar
|
./gradlew assemblePrerelease build androidSourcesJar
|
||||||
|
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
|
||||||
env:
|
env:
|
||||||
SIGNING_KEY_ALIAS: "key0"
|
SIGNING_KEY_ALIAS: "key0"
|
||||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||||
|
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||||
|
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||||
- name: Create pre-release
|
- name: Create pre-release
|
||||||
uses: "marvinpinto/action-automatic-releases@latest"
|
uses: "marvinpinto/action-automatic-releases@latest"
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
10
.github/workflows/pull_request.yml
vendored
10
.github/workflows/pull_request.yml
vendored
|
|
@ -6,18 +6,18 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '11'
|
java-version: '17'
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
- name: Run Gradle
|
- name: Run Gradle
|
||||||
run: ./gradlew assemblePrereleaseDebug
|
run: ./gradlew assemblePrereleaseDebug
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pull-request-build
|
name: pull-request-build
|
||||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||||
|
|
|
||||||
13
.github/workflows/update_locales.yml
vendored
13
.github/workflows/update_locales.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: Update locale lists
|
name: Fix locale issues
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -9,7 +9,7 @@ on:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: "locale-list"
|
group: "locale"
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -18,14 +18,17 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate access token
|
- name: Generate access token
|
||||||
id: generate_token
|
id: generate_token
|
||||||
uses: tibdex/github-app-token@v1
|
uses: tibdex/github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app_id: ${{ secrets.GH_APP_ID }}
|
app_id: ${{ secrets.GH_APP_ID }}
|
||||||
private_key: ${{ secrets.GH_APP_KEY }}
|
private_key: ${{ secrets.GH_APP_KEY }}
|
||||||
repository: "recloudstream/cloudstream"
|
repository: "recloudstream/cloudstream"
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip3 install lxml
|
||||||
- name: Edit files
|
- name: Edit files
|
||||||
run: |
|
run: |
|
||||||
python3 .github/locales.py
|
python3 .github/locales.py
|
||||||
|
|
@ -35,5 +38,5 @@ jobs:
|
||||||
git config --local user.name "recloudstream[bot]"
|
git config --local user.name "recloudstream[bot]"
|
||||||
git add .
|
git add .
|
||||||
# "echo" returns true so the build succeeds, even if no changed files
|
# "echo" returns true so the build succeeds, even if no changed files
|
||||||
git commit -m 'update list of locales' || echo
|
git commit -m 'chore(locales): fix locale issues' || echo
|
||||||
git push
|
git push
|
||||||
|
|
|
||||||
2
.idea/compiler.xml
generated
2
.idea/compiler.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="11" />
|
<bytecodeTargetLevel target="17" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
7
.idea/gradle.xml
generated
7
.idea/gradle.xml
generated
|
|
@ -4,17 +4,16 @@
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="delegatedBuild" value="true" />
|
|
||||||
<option name="testRunner" value="GRADLE" />
|
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="11" />
|
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/library" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
<option name="resolveExternalAnnotations" value="false" />
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,9 @@
|
||||||
+ **AdFree**, No ads whatsoever
|
+ **AdFree**, No ads whatsoever
|
||||||
+ No tracking/analytics
|
+ No tracking/analytics
|
||||||
+ Bookmarks
|
+ Bookmarks
|
||||||
+ Download and stream movies, tv-shows and anime
|
+ Phone and TV support
|
||||||
+ Chromecast
|
+ Chromecast
|
||||||
|
+ Extension system for personal customization
|
||||||
### Screenshots:
|
|
||||||
|
|
||||||
<img src="./.github/home.jpg" height="400"/><img src="./.github/search.jpg" height="400"/><img src="./.github/downloads.jpg" height="400"/><img src="./.github/results.jpg" height="400"/>
|
|
||||||
<img src="./.github/player.jpg" height="200"/>
|
|
||||||
|
|
||||||
### Supported languages:
|
### Supported languages:
|
||||||
<a href="https://hosted.weblate.org/engage/cloudstream/">
|
<a href="https://hosted.weblate.org/engage/cloudstream/">
|
||||||
|
|
|
||||||
6
app/CMakeLists.txt
Normal file
6
app/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Set this to the minimum version your project supports.
|
||||||
|
cmake_minimum_required(VERSION 3.18)
|
||||||
|
project(CrashHandler)
|
||||||
|
find_library(log-lib log)
|
||||||
|
add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
|
||||||
|
target_link_libraries(native-lib ${log-lib})
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import com.android.build.gradle.api.BaseVariantOutput
|
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import org.jetbrains.dokka.gradle.DokkaTask
|
import org.jetbrains.dokka.gradle.DokkaTask
|
||||||
|
import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
|
||||||
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
id("com.google.devtools.ksp")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
id("kotlin-kapt")
|
|
||||||
id("kotlin-android-extensions")
|
|
||||||
id("org.jetbrains.dokka")
|
id("org.jetbrains.dokka")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,7 +20,7 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
|
||||||
workingDir = projectDir
|
workingDir = projectDir
|
||||||
commandLine = this@execute.split(Regex("\\s"))
|
commandLine = this@execute.split(Regex("\\s"))
|
||||||
standardOutput = baot
|
standardOutput = baot
|
||||||
}.exitValue == 0)
|
}.exitValue == 0)
|
||||||
String(baot.toByteArray()).trim()
|
String(baot.toByteArray()).trim()
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
@ -28,9 +29,21 @@ android {
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewBinding {
|
||||||
|
enable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* disable this for now
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path("CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("prerelease") {
|
if (prereleaseStoreFile != null) {
|
||||||
if (prereleaseStoreFile != null) {
|
create("prerelease") {
|
||||||
storeFile = file(prereleaseStoreFile)
|
storeFile = file(prereleaseStoreFile)
|
||||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||||
|
|
@ -39,33 +52,44 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileSdk = 33
|
compileSdk = 34
|
||||||
buildToolsVersion = "30.0.3"
|
buildToolsVersion = "34.0.0"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.lagradost.cloudstream3"
|
applicationId = "com.lagradost.cloudstream3"
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 33
|
targetSdk = 33 /* Android 14 is Fu*ked
|
||||||
|
^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
|
||||||
versionCode = 57
|
versionCode = 64
|
||||||
versionName = "4.0.0"
|
versionName = "4.4.0"
|
||||||
|
|
||||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||||
|
|
||||||
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
|
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
|
||||||
|
|
||||||
resValue("bool", "is_prerelease", "false")
|
resValue("bool", "is_prerelease", "false")
|
||||||
|
|
||||||
|
// Reads local.properties
|
||||||
|
val localProperties = gradleLocalProperties(rootDir)
|
||||||
|
|
||||||
|
buildConfigField(
|
||||||
|
"long",
|
||||||
|
"BUILD_DATE",
|
||||||
|
"${System.currentTimeMillis()}"
|
||||||
|
)
|
||||||
buildConfigField(
|
buildConfigField(
|
||||||
"String",
|
"String",
|
||||||
"BUILDDATE",
|
"SIMKL_CLIENT_ID",
|
||||||
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
|
"\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
|
||||||
|
)
|
||||||
|
buildConfigField(
|
||||||
|
"String",
|
||||||
|
"SIMKL_CLIENT_SECRET",
|
||||||
|
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
|
||||||
)
|
)
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
kapt {
|
ksp {
|
||||||
includeCompileClasspath = true
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
arg("exportSchema", "true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,14 +98,21 @@ android {
|
||||||
isDebuggable = false
|
isDebuggable = false
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions.add("state")
|
flavorDimensions.add("state")
|
||||||
productFlavors {
|
productFlavors {
|
||||||
create("stable") {
|
create("stable") {
|
||||||
|
|
@ -93,25 +124,31 @@ android {
|
||||||
resValue("bool", "is_prerelease", "true")
|
resValue("bool", "is_prerelease", "true")
|
||||||
buildConfigField("boolean", "BETA", "true")
|
buildConfigField("boolean", "BETA", "true")
|
||||||
applicationIdSuffix = ".prerelease"
|
applicationIdSuffix = ".prerelease"
|
||||||
signingConfig = signingConfigs.getByName("prerelease")
|
if (signingConfigs.names.contains("prerelease")) {
|
||||||
|
signingConfig = signingConfigs.getByName("prerelease")
|
||||||
|
} else {
|
||||||
|
logger.warn("No prerelease signing config!")
|
||||||
|
}
|
||||||
versionNameSuffix = "-PRE"
|
versionNameSuffix = "-PRE"
|
||||||
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
freeCompilerArgs = listOf("-Xjvm-default=compatibility")
|
|
||||||
}
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError = false
|
abortOnError = false
|
||||||
checkReleaseBuilds = false
|
checkReleaseBuilds = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
namespace = "com.lagradost.cloudstream3"
|
namespace = "com.lagradost.cloudstream3"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,122 +157,132 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.google.android.mediahome:video:1.0.0")
|
// Testing
|
||||||
implementation("androidx.test.ext:junit-ktx:1.1.3")
|
|
||||||
testImplementation("org.json:json:20180813")
|
|
||||||
|
|
||||||
implementation("androidx.core:core-ktx:1.8.0")
|
|
||||||
implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0
|
|
||||||
|
|
||||||
// dont change this to 1.6.0 it looks ugly af
|
|
||||||
implementation("com.google.android.material:material:1.5.0")
|
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
|
||||||
implementation("androidx.navigation:navigation-fragment-ktx:2.5.1")
|
|
||||||
implementation("androidx.navigation:navigation-ui-ktx:2.5.1")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
testImplementation("org.json:json:20240303")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
|
androidTestImplementation("androidx.test:core")
|
||||||
|
implementation("androidx.test.ext:junit-ktx:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||||
|
|
||||||
//implementation("io.karn:khttp-android:0.1.2") //okhttp instead
|
// Android Core & Lifecycle
|
||||||
// implementation("org.jsoup:jsoup:1.13.1")
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
|
||||||
implementation("androidx.preference:preference-ktx:1.2.0")
|
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
|
||||||
implementation("com.github.bumptech.glide:glide:4.13.1")
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
|
||||||
kapt("com.github.bumptech.glide:compiler:4.13.1")
|
|
||||||
implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
|
|
||||||
|
|
||||||
|
// Design & UI
|
||||||
implementation("jp.wasabeef:glide-transformations:4.3.0")
|
implementation("jp.wasabeef:glide-transformations:4.3.0")
|
||||||
|
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
|
// Glide Module
|
||||||
|
ksp("com.github.bumptech.glide:ksp:4.16.0")
|
||||||
|
implementation("com.github.bumptech.glide:glide:4.16.0")
|
||||||
|
implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
|
||||||
|
|
||||||
// Exoplayer
|
// For KSP -> Official Annotation Processors are Not Yet Supported for KSP
|
||||||
implementation("com.google.android.exoplayer:exoplayer:2.18.2")
|
ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
|
||||||
implementation("com.google.android.exoplayer:extension-cast:2.18.2")
|
implementation("com.google.guava:guava:33.2.1-android")
|
||||||
implementation("com.google.android.exoplayer:extension-mediasession:2.18.2")
|
implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
|
||||||
implementation("com.google.android.exoplayer:extension-okhttp:2.18.2")
|
|
||||||
|
|
||||||
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
|
// Media 3 (ExoPlayer)
|
||||||
|
implementation("androidx.media3:media3-ui:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-cast:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-common:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-session:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.1.1")
|
||||||
|
implementation("com.google.android.mediahome:video:1.0.0")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
|
||||||
|
|
||||||
// Bug reports
|
// PlayBack
|
||||||
implementation("ch.acra:acra-core:5.8.4")
|
implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
|
||||||
implementation("ch.acra:acra-toast:5.8.4")
|
implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
|
||||||
|
implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
|
||||||
|
^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
|
||||||
|
implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
|
||||||
|
|
||||||
compileOnly("com.google.auto.service:auto-service-annotations:1.0")
|
// Crash Reports (AcraApplication.kt)
|
||||||
//either for java sources:
|
implementation("ch.acra:acra-core:5.11.3")
|
||||||
annotationProcessor("com.google.auto.service:auto-service:1.0")
|
implementation("ch.acra:acra-toast:5.11.3")
|
||||||
//or for kotlin sources (requires kapt gradle plugin):
|
|
||||||
kapt("com.google.auto.service:auto-service:1.0")
|
|
||||||
|
|
||||||
// subtitle color picker
|
|
||||||
implementation("com.jaredrummler:colorpicker:1.1.0")
|
|
||||||
|
|
||||||
//run JS
|
|
||||||
// do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
|
|
||||||
// available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown
|
|
||||||
implementation("org.mozilla:rhino:1.7.13")
|
|
||||||
|
|
||||||
// TorrentStream
|
|
||||||
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
|
|
||||||
|
|
||||||
// Downloading
|
|
||||||
implementation("androidx.work:work-runtime:2.7.1")
|
|
||||||
implementation("androidx.work:work-runtime-ktx:2.7.1")
|
|
||||||
|
|
||||||
// Networking
|
|
||||||
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
|
||||||
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
|
|
||||||
implementation("com.github.Blatzar:NiceHttp:0.4.2")
|
|
||||||
// To fix SSL fuckery on android 9
|
|
||||||
implementation("org.conscrypt:conscrypt-android:2.2.1")
|
|
||||||
// Util to skip the URI file fuckery 🙏
|
|
||||||
implementation("com.github.tachiyomiorg:unifile:17bec43")
|
|
||||||
|
|
||||||
// API because cba maintaining it myself
|
|
||||||
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
|
|
||||||
|
|
||||||
implementation("com.github.discord:OverlappingPanels:0.1.3")
|
|
||||||
// debugImplementation because LeakCanary should only run in debug builds.
|
|
||||||
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
|
|
||||||
|
|
||||||
// for shimmer when loading
|
|
||||||
implementation("com.facebook.shimmer:shimmer:0.5.0")
|
|
||||||
|
|
||||||
|
// UI Stuff
|
||||||
|
implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
|
||||||
|
implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
|
||||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||||
|
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
|
||||||
|
implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
|
||||||
|
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
|
||||||
|
implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
|
||||||
|
|
||||||
// used for subtitle decoding https://github.com/albfernandez/juniversalchardet
|
// Extensions & Other Libs
|
||||||
implementation("com.github.albfernandez:juniversalchardet:2.4.0")
|
implementation("org.mozilla:rhino:1.7.15") // run JavaScript
|
||||||
|
implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
|
||||||
|
implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
|
||||||
|
implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
|
||||||
|
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
|
||||||
|
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
|
||||||
|
Level 25 or Less. */
|
||||||
|
|
||||||
// slow af yt
|
// Downloading & Networking
|
||||||
//implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT")
|
implementation("androidx.work:work-runtime:2.9.0")
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
|
||||||
|
|
||||||
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L190
|
implementation(project(":library") {
|
||||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:9ffdd0948b2ecd82655f5ff2a3e127b2b7695d5b")
|
// There does not seem to be a good way of getting the android flavor.
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
|
val isDebug = gradle.startParameter.taskRequests.any { task ->
|
||||||
|
task.args.any { arg ->
|
||||||
|
arg.contains("debug", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Library/extensions searching with Levenshtein distance
|
this.extra.set("isDebug", isDebug)
|
||||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
})
|
||||||
|
|
||||||
// color pallette for images -> colors
|
|
||||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("androidSourcesJar", Jar::class) {
|
tasks.register<Jar>("androidSourcesJar") {
|
||||||
archiveClassifier.set("sources")
|
archiveClassifier.set("sources")
|
||||||
from(android.sourceSets.getByName("main").java.srcDirs) //full sources
|
from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is used by the gradlew plugin
|
tasks.register<Copy>("copyJar") {
|
||||||
tasks.register("makeJar", Copy::class) {
|
from(
|
||||||
from("build/intermediates/compile_app_classes_jar/prereleaseDebug")
|
"build/intermediates/compile_app_classes_jar/prereleaseDebug",
|
||||||
into("build")
|
"../library/build/libs"
|
||||||
include("classes.jar")
|
)
|
||||||
dependsOn("build")
|
into("build/app-classes")
|
||||||
|
include("classes.jar", "library-jvm*.jar")
|
||||||
|
// Remove the version
|
||||||
|
rename("library-jvm.*.jar", "library-jvm.jar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the app classes and the library classes into classes.jar
|
||||||
|
tasks.register<Jar>("makeJar") {
|
||||||
|
// Duplicates cause hard to catch errors, better to fail at compile time.
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.FAIL
|
||||||
|
dependsOn(tasks.getByName("copyJar"))
|
||||||
|
from(
|
||||||
|
zipTree("build/app-classes/classes.jar"),
|
||||||
|
zipTree("build/app-classes/library-jvm.jar")
|
||||||
|
)
|
||||||
|
destinationDirectory.set(layout.buildDirectory)
|
||||||
|
archivesName = "classes"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<KotlinCompile> {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<DokkaTask>().configureEach {
|
tasks.withType<DokkaTask>().configureEach {
|
||||||
|
|
@ -248,6 +295,7 @@ tasks.withType<DokkaTask>().configureEach {
|
||||||
|
|
||||||
// URL showing where the source code can be accessed through the web browser
|
// URL showing where the source code can be accessed through the web browser
|
||||||
remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
|
remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
|
||||||
|
|
||||||
// Suffix which is used to append the line number to the URL. Use #L for GitHub
|
// Suffix which is used to append the line number to the URL. Use #L for GitHub
|
||||||
remoteLineSuffix.set("#L")
|
remoteLineSuffix.set("#L")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,57 @@
|
||||||
package com.lagradost.cloudstream3
|
package com.lagradost.cloudstream3
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentResultBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.RepositoryItemBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.TestingUtils
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrumented test, which will execute on an Android device.
|
* Instrumented test, which will execute on an Android device.
|
||||||
*
|
*
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
*/
|
*/
|
||||||
|
class TestApplication : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
|
||||||
|
super.onCreate(savedInstanceState, persistentState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ExampleInstrumentedTest {
|
class ExampleInstrumentedTest {
|
||||||
//@Test
|
private fun getAllProviders(): Array<MainAPI> {
|
||||||
//fun useAppContext() {
|
println("Providers: ${APIHolder.allProviders.size}")
|
||||||
// // Context of the app under test.
|
return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
|
||||||
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
// assertEquals("com.lagradost.cloudstream3", appContext.packageName)
|
|
||||||
//}
|
|
||||||
|
|
||||||
private fun getAllProviders(): List<MainAPI> {
|
|
||||||
return APIHolder.allProviders //.filter { !it.usesWebView }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
|
|
||||||
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
|
|
||||||
if (url == null) return true
|
|
||||||
var linksLoaded = 0
|
|
||||||
try {
|
|
||||||
val success = api.loadLinks(url, false, {}) { link ->
|
|
||||||
Assert.assertTrue(
|
|
||||||
"Api ${api.name} returns link with invalid Quality",
|
|
||||||
Qualities.values().map { it.value }.contains(link.quality)
|
|
||||||
)
|
|
||||||
Assert.assertTrue(
|
|
||||||
"Api ${api.name} returns link with invalid url ${link.url}",
|
|
||||||
link.url.length > 4
|
|
||||||
)
|
|
||||||
linksLoaded++
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
return linksLoaded > 0
|
|
||||||
}
|
|
||||||
Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e.cause is NotImplementedError) {
|
|
||||||
Assert.fail("Provider has not implemented .loadLinks")
|
|
||||||
}
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
|
|
||||||
val searchQueries = listOf("over", "iron", "guy")
|
|
||||||
var correctResponses = 0
|
|
||||||
var searchResult: List<SearchResponse>? = null
|
|
||||||
for (query in searchQueries) {
|
|
||||||
val response = try {
|
|
||||||
api.search(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e.cause is NotImplementedError) {
|
|
||||||
Assert.fail("Provider has not implemented .search")
|
|
||||||
}
|
|
||||||
logError(e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (!response.isNullOrEmpty()) {
|
|
||||||
correctResponses++
|
|
||||||
if (searchResult == null) {
|
|
||||||
searchResult = response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (correctResponses == 0 || searchResult == null) {
|
|
||||||
System.err.println("Api ${api.name} did not return any valid search responses")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var validResults = false
|
|
||||||
for (result in searchResult) {
|
|
||||||
Assert.assertEquals(
|
|
||||||
"Invalid apiName on response on ${api.name}",
|
|
||||||
result.apiName,
|
|
||||||
api.name
|
|
||||||
)
|
|
||||||
val load = api.load(result.url) ?: continue
|
|
||||||
Assert.assertEquals(
|
|
||||||
"Invalid apiName on load on ${api.name}",
|
|
||||||
load.apiName,
|
|
||||||
result.apiName
|
|
||||||
)
|
|
||||||
Assert.assertTrue(
|
|
||||||
"Api ${api.name} on load does not contain any of the supportedTypes",
|
|
||||||
api.supportedTypes.contains(load.type)
|
|
||||||
)
|
|
||||||
when (load) {
|
|
||||||
is AnimeLoadResponse -> {
|
|
||||||
val gotNoEpisodes =
|
|
||||||
load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
|
|
||||||
|
|
||||||
if (gotNoEpisodes) {
|
|
||||||
println("Api ${api.name} got no episodes on ${load.url}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
|
|
||||||
validResults = loadLinks(api, url)
|
|
||||||
if (!validResults) continue
|
|
||||||
}
|
|
||||||
is MovieLoadResponse -> {
|
|
||||||
val gotNoEpisodes = load.dataUrl.isBlank()
|
|
||||||
if (gotNoEpisodes) {
|
|
||||||
println("Api ${api.name} got no movie on ${load.url}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
validResults = loadLinks(api, load.dataUrl)
|
|
||||||
if (!validResults) continue
|
|
||||||
}
|
|
||||||
is TvSeriesLoadResponse -> {
|
|
||||||
val gotNoEpisodes = load.episodes.isEmpty()
|
|
||||||
if (gotNoEpisodes) {
|
|
||||||
println("Api ${api.name} got no episodes on ${load.url}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
validResults = loadLinks(api, load.episodes.first().data)
|
|
||||||
if (!validResults) continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (!validResults) {
|
|
||||||
System.err.println("Api ${api.name} did not load on any")
|
|
||||||
}
|
|
||||||
|
|
||||||
return validResults
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e.cause is NotImplementedError) {
|
|
||||||
Assert.fail("Provider has not implemented .load")
|
|
||||||
}
|
|
||||||
logError(e)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -158,7 +60,78 @@ class ExampleInstrumentedTest {
|
||||||
println("Done providersExist")
|
println("Done providersExist")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws
|
||||||
|
private inline fun <reified T : ViewBinding> testAllLayouts(
|
||||||
|
activity: Activity,
|
||||||
|
vararg layouts: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
val bind = T::class.java.methods.first { it.name == "bind" }
|
||||||
|
val inflater = LayoutInflater.from(activity)
|
||||||
|
for (layout in layouts) {
|
||||||
|
val root = inflater.inflate(layout, null, false)
|
||||||
|
bind.invoke(null, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Throws
|
||||||
|
fun layoutTest() {
|
||||||
|
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||||
|
scenario.onActivity { activity: MainActivity ->
|
||||||
|
// FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same
|
||||||
|
//testAllLayouts<FragmentHomeHeadBinding>(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
|
||||||
|
//testAllLayouts<FragmentHomeHeadTvBinding>(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
|
||||||
|
|
||||||
|
// main cant be tested
|
||||||
|
// testAllLayouts<ActivityMainTvBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
|
||||||
|
// testAllLayouts<ActivityMainBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
|
||||||
|
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
|
||||||
|
|
||||||
|
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
||||||
|
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
|
||||||
|
|
||||||
|
// testAllLayouts<FragmentResultBinding>(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
|
||||||
|
// testAllLayouts<FragmentResultTvBinding>(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
|
||||||
|
|
||||||
|
testAllLayouts<PlayerCustomLayoutBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
|
||||||
|
testAllLayouts<PlayerCustomLayoutTvBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
|
||||||
|
testAllLayouts<TrailerCustomLayoutBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
|
||||||
|
|
||||||
|
testAllLayouts<RepositoryItemBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
|
||||||
|
testAllLayouts<RepositoryItemTvBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
|
||||||
|
|
||||||
|
testAllLayouts<RepositoryItemBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
|
||||||
|
testAllLayouts<RepositoryItemTvBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
|
||||||
|
|
||||||
|
testAllLayouts<FragmentHomeBinding>(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
|
||||||
|
testAllLayouts<FragmentHomeTvBinding>(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
|
||||||
|
|
||||||
|
testAllLayouts<FragmentSearchBinding>(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
|
||||||
|
testAllLayouts<FragmentSearchTvBinding>(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
|
||||||
|
|
||||||
|
testAllLayouts<HomeResultGridBinding>(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid)
|
||||||
|
//testAllLayouts<HomeResultGridExpandedBinding>(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ???
|
||||||
|
|
||||||
|
testAllLayouts<SearchResultGridExpandedBinding>(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
|
||||||
|
testAllLayouts<SearchResultGridBinding>(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
|
||||||
|
|
||||||
|
|
||||||
|
// testAllLayouts<HomeScrollViewBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
|
||||||
|
// testAllLayouts<HomeScrollViewTvBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
|
||||||
|
|
||||||
|
testAllLayouts<HomepageParentTvBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
|
||||||
|
testAllLayouts<HomepageParentEmulatorBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
|
||||||
|
testAllLayouts<HomepageParentBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
|
||||||
|
|
||||||
|
testAllLayouts<FragmentLibraryTvBinding>(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
|
||||||
|
testAllLayouts<FragmentLibraryBinding>(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Throws(AssertionError::class)
|
||||||
fun providerCorrectData() {
|
fun providerCorrectData() {
|
||||||
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
|
||||||
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
|
||||||
|
|
@ -180,68 +153,20 @@ class ExampleInstrumentedTest {
|
||||||
@Test
|
@Test
|
||||||
fun providerCorrectHomepage() {
|
fun providerCorrectHomepage() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
getAllProviders().amap { api ->
|
getAllProviders().toList().amap { api ->
|
||||||
if (api.hasMainPage) {
|
TestingUtils.testHomepage(api, TestingUtils.Logger())
|
||||||
try {
|
|
||||||
val f = api.mainPage.first()
|
|
||||||
val homepage =
|
|
||||||
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
|
|
||||||
when {
|
|
||||||
homepage == null -> {
|
|
||||||
System.err.println("Homepage provider ${api.name} did not correctly load homepage!")
|
|
||||||
}
|
|
||||||
homepage.items.isEmpty() -> {
|
|
||||||
System.err.println("Homepage provider ${api.name} does not contain any items!")
|
|
||||||
}
|
|
||||||
homepage.items.any { it.list.isEmpty() } -> {
|
|
||||||
System.err.println("Homepage provider ${api.name} does not have any items on result!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e.cause is NotImplementedError) {
|
|
||||||
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
|
|
||||||
}
|
|
||||||
logError(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println("Done providerCorrectHomepage")
|
println("Done providerCorrectHomepage")
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Test
|
|
||||||
// fun testSingleProvider() {
|
|
||||||
// testSingleProviderApi(ThenosProvider())
|
|
||||||
// }
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun providerCorrect() {
|
fun testAllProvidersCorrect() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val invalidProvider = ArrayList<Pair<MainAPI, Exception?>>()
|
TestingUtils.getDeferredProviderTests(
|
||||||
val providers = getAllProviders()
|
this,
|
||||||
providers.amap { api ->
|
getAllProviders(),
|
||||||
try {
|
) { _, _ -> }
|
||||||
println("Trying $api")
|
|
||||||
if (testSingleProviderApi(api)) {
|
|
||||||
println("Success $api")
|
|
||||||
} else {
|
|
||||||
System.err.println("Error $api")
|
|
||||||
invalidProvider.add(Pair(api, null))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
invalidProvider.add(Pair(api, e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (invalidProvider.isEmpty()) {
|
|
||||||
println("No Invalid providers! :D")
|
|
||||||
} else {
|
|
||||||
println("Invalid providers are: ")
|
|
||||||
for (provider in invalidProvider) {
|
|
||||||
println("${provider.first}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
println("Done providerCorrect")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
|
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Downloads on low api devices -->
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- Downloads on low api devices -->
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <!-- Plugin API -->
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <!-- Plugin API -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- some dependency needs this -->
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <!-- some dependency needs this -->
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!-- Used for app update -->
|
||||||
|
|
@ -14,8 +14,14 @@
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> <!-- Used for Android TV watch next -->
|
||||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
|
<!-- Required for getting arbitrary Aniyomi packages -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
|
||||||
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!– Used for getting if vlc is installed –> -->
|
|
||||||
<!-- Fixes android tv fuckery -->
|
<!-- Fixes android tv fuckery -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
|
|
@ -35,9 +41,11 @@
|
||||||
<application
|
<application
|
||||||
android:name=".AcraApplication"
|
android:name=".AcraApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:appCategory="video"
|
android:appCategory="video"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
|
|
@ -45,7 +53,7 @@
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="o">
|
tools:targetApi="tiramisu">
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
|
@ -61,7 +69,9 @@
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:screenOrientation="userLandscape"
|
android:screenOrientation="userLandscape"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true"
|
||||||
|
android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
|
||||||
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
|
@ -87,16 +97,20 @@
|
||||||
-->
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
<intent-filter android:exported="true">
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<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="cloudstreamplayer" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
@ -151,6 +165,21 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.account.AccountSelectActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:exported="true">
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.EasterEggMonke"
|
android:name=".ui.EasterEggMonke"
|
||||||
android:exported="true" />
|
android:exported="true" />
|
||||||
|
|
@ -158,13 +187,14 @@
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
android:exported="true">
|
android:exported="false">
|
||||||
<intent-filter android:exported="true">
|
<intent-filter android:exported="false">
|
||||||
<action android:name="restart_service" />
|
<action android:name="restart_service" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
android:name=".services.VideoDownloadService"
|
android:name=".services.VideoDownloadService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
@ -174,6 +204,7 @@
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
android:name=".utils.PackageInstallerService"
|
android:name=".utils.PackageInstallerService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
|
|
||||||
28
app/src/main/cpp/native-lib.cpp
Normal file
28
app/src/main/cpp/native-lib.cpp
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#include <jni.h>
|
||||||
|
#include <csignal>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#define TAG "CloudStream Crash Handler"
|
||||||
|
volatile sig_atomic_t gSignalStatus = 0;
|
||||||
|
void handleNativeCrash(int signal) {
|
||||||
|
gSignalStatus = signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT void JNICALL
|
||||||
|
Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
|
||||||
|
#define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
|
||||||
|
REGISTER_SIGNAL(SIGSEGV)
|
||||||
|
#undef REGISTER_SIGNAL
|
||||||
|
}
|
||||||
|
|
||||||
|
//extern "C" JNIEXPORT void JNICALL
|
||||||
|
//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
|
||||||
|
// int *p = nullptr;
|
||||||
|
// *p = 0;
|
||||||
|
//}
|
||||||
|
|
||||||
|
extern "C" JNIEXPORT int JNICALL
|
||||||
|
Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
|
||||||
|
//__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
|
||||||
|
return gSignalStatus;
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,14 @@ import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.google.auto.service.AutoService
|
import com.lagradost.api.setContext
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||||
|
|
@ -32,27 +34,26 @@ import org.acra.sender.ReportSenderFactory
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.PrintStream
|
import java.io.PrintStream
|
||||||
import java.lang.Exception
|
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
|
||||||
class CustomReportSender : ReportSender {
|
class CustomReportSender : ReportSender {
|
||||||
// Sends all your crashes to google forms
|
// Sends all your crashes to google forms
|
||||||
override fun send(context: Context, errorContent: CrashReportData) {
|
override fun send(context: Context, errorContent: CrashReportData) {
|
||||||
println("Sending report")
|
println("Sending report")
|
||||||
val url =
|
val url =
|
||||||
"https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse"
|
"https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"entry.753293084" to errorContent.toJSON()
|
"entry.1993829403" to errorContent.toJSON()
|
||||||
)
|
)
|
||||||
|
|
||||||
thread { // to not run it on main thread
|
thread { // to not run it on main thread
|
||||||
runBlocking {
|
runBlocking {
|
||||||
suspendSafeApiCall {
|
suspendSafeApiCall {
|
||||||
val post = app.post(url, data = data)
|
app.post(url, data = data)
|
||||||
println("Report response: $post")
|
//println("Report response: $post")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +66,6 @@ class CustomReportSender : ReportSender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@AutoService(ReportSenderFactory::class)
|
|
||||||
class CustomSenderFactory : ReportSenderFactory {
|
class CustomSenderFactory : ReportSenderFactory {
|
||||||
override fun create(context: Context, config: CoreConfiguration): ReportSender {
|
override fun create(context: Context, config: CoreConfiguration): ReportSender {
|
||||||
return CustomReportSender()
|
return CustomReportSender()
|
||||||
|
|
@ -82,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
||||||
ACRA.errorReporter.handleException(error)
|
ACRA.errorReporter.handleException(error)
|
||||||
try {
|
try {
|
||||||
PrintStream(errorFile).use { ps ->
|
PrintStream(errorFile).use { ps ->
|
||||||
ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
|
ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
|
||||||
ps.println(
|
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
|
||||||
String.format(
|
|
||||||
"Fatal exception on thread %s (%d)",
|
|
||||||
thread.name,
|
|
||||||
thread.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
error.printStackTrace(ps)
|
error.printStackTrace(ps)
|
||||||
}
|
}
|
||||||
} catch (ignored: FileNotFoundException) {
|
} catch (ignored: FileNotFoundException) {
|
||||||
|
|
@ -104,12 +98,16 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
||||||
}
|
}
|
||||||
|
|
||||||
class AcraApplication : Application() {
|
class AcraApplication : Application() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) {
|
ExceptionHandler(filesDir.resolve("last_error")) {
|
||||||
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
||||||
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||||
})
|
}.also {
|
||||||
|
exceptionHandler = it
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
|
|
@ -121,10 +119,10 @@ class AcraApplication : Application() {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.JSON
|
reportFormat = StringFormat.JSON
|
||||||
|
|
||||||
reportContent = arrayOf(
|
reportContent = listOf(
|
||||||
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
|
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
|
||||||
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
|
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
|
||||||
ReportField.STACK_TRACE
|
ReportField.STACK_TRACE,
|
||||||
)
|
)
|
||||||
|
|
||||||
// removed this due to bug when starting the app, moved it to when it actually crashes
|
// removed this due to bug when starting the app, moved it to when it actually crashes
|
||||||
|
|
@ -137,6 +135,8 @@ class AcraApplication : Application() {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
var exceptionHandler: ExceptionHandler? = null
|
||||||
|
|
||||||
/** Use to get activity from Context */
|
/** Use to get activity from Context */
|
||||||
tailrec fun Context.getActivity(): Activity? = this as? Activity
|
tailrec fun Context.getActivity(): Activity? = this as? Activity
|
||||||
?: (this as? ContextWrapper)?.baseContext?.getActivity()
|
?: (this as? ContextWrapper)?.baseContext?.getActivity()
|
||||||
|
|
@ -146,8 +146,17 @@ class AcraApplication : Application() {
|
||||||
get() = _context?.get()
|
get() = _context?.get()
|
||||||
private set(value) {
|
private set(value) {
|
||||||
_context = WeakReference(value)
|
_context = WeakReference(value)
|
||||||
|
setContext(WeakReference(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
|
||||||
|
return context?.getKey(path, valueType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T : Any> setKeyClass(path: String, value: T) {
|
||||||
|
context?.setKey(path, value)
|
||||||
|
}
|
||||||
|
|
||||||
fun removeKeys(folder: String): Int? {
|
fun removeKeys(folder: String): Int? {
|
||||||
return context?.removeKeys(folder)
|
return context?.removeKeys(folder)
|
||||||
}
|
}
|
||||||
|
|
@ -199,10 +208,9 @@ class AcraApplication : Application() {
|
||||||
fun openBrowser(url: String, activity: FragmentActivity?) {
|
fun openBrowser(url: String, activity: FragmentActivity?) {
|
||||||
openBrowser(
|
openBrowser(
|
||||||
url,
|
url,
|
||||||
isTvSettings(),
|
isLayout(TV or EMULATOR),
|
||||||
activity?.supportFragmentManager?.fragments?.lastOrNull()
|
activity?.supportFragmentManager?.fragments?.lastOrNull()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,11 +5,16 @@ import android.app.Activity
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.DisplayMetrics
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.*
|
import android.view.Gravity
|
||||||
import android.widget.TextView
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.NO_ID
|
||||||
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
@ -18,15 +23,21 @@ import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.gms.cast.framework.CastSession
|
import com.google.android.gms.cast.framework.CastSession
|
||||||
|
import com.google.android.material.chip.ChipGroup
|
||||||
|
import com.google.android.material.navigationrail.NavigationRailView
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.resumeApps
|
||||||
|
import com.lagradost.cloudstream3.databinding.ToastBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
||||||
import com.lagradost.cloudstream3.ui.result.UiText
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
|
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper
|
import com.lagradost.cloudstream3.utils.UIHelper
|
||||||
|
|
@ -34,14 +45,50 @@ import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
|
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import java.util.*
|
import java.lang.ref.WeakReference
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
enum class FocusDirection {
|
||||||
|
Start,
|
||||||
|
End,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
object CommonActivity {
|
object CommonActivity {
|
||||||
|
|
||||||
|
private var _activity: WeakReference<Activity>? = null
|
||||||
|
var activity
|
||||||
|
get() = _activity?.get()
|
||||||
|
private set(value) {
|
||||||
|
_activity = WeakReference(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
fun setActivityInstance(newActivity: Activity?) {
|
||||||
|
activity = newActivity
|
||||||
|
}
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
fun Activity?.getCastSession(): CastSession? {
|
fun Activity?.getCastSession(): CastSession? {
|
||||||
return (this as MainActivity?)?.mSessionManager?.currentCastSession
|
return (this as MainActivity?)?.mSessionManager?.currentCastSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
|
||||||
|
|
||||||
|
// screenWidth and screenHeight does always
|
||||||
|
// refer to the screen while in landscape mode
|
||||||
|
val screenWidth: Int
|
||||||
|
get() {
|
||||||
|
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||||
|
}
|
||||||
|
val screenHeight: Int
|
||||||
|
get() {
|
||||||
|
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var canEnterPipMode: Boolean = false
|
var canEnterPipMode: Boolean = false
|
||||||
var canShowPipMode: Boolean = false
|
var canShowPipMode: Boolean = false
|
||||||
|
|
@ -53,9 +100,32 @@ object CommonActivity {
|
||||||
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
||||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||||
|
|
||||||
|
private var currentToast: Toast? = null
|
||||||
|
|
||||||
var currentToast: Toast? = null
|
fun showToast(@StringRes message: Int, duration: Int? = null) {
|
||||||
|
val act = activity ?: return
|
||||||
|
act.runOnUiThread {
|
||||||
|
showToast(act, act.getString(message), duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showToast(message: String?, duration: Int? = null) {
|
||||||
|
val act = activity ?: return
|
||||||
|
act.runOnUiThread {
|
||||||
|
showToast(act, message, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showToast(message: UiText?, duration: Int? = null) {
|
||||||
|
val act = activity ?: return
|
||||||
|
if (message == null) return
|
||||||
|
act.runOnUiThread {
|
||||||
|
showToast(act, message.asString(act), duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@MainThread
|
||||||
fun showToast(act: Activity?, text: UiText, duration: Int) {
|
fun showToast(act: Activity?, text: UiText, duration: Int) {
|
||||||
if (act == null) return
|
if (act == null) return
|
||||||
text.asStringNull(act)?.let {
|
text.asStringNull(act)?.let {
|
||||||
|
|
@ -86,25 +156,19 @@ object CommonActivity {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val inflater =
|
val binding = ToastBinding.inflate(act.layoutInflater)
|
||||||
act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
binding.text.text = message.trim()
|
||||||
|
|
||||||
val layout: View = inflater.inflate(
|
|
||||||
R.layout.toast,
|
|
||||||
act.findViewById<View>(R.id.toast_layout_root) as ViewGroup?
|
|
||||||
)
|
|
||||||
|
|
||||||
val text = layout.findViewById(R.id.text) as TextView
|
|
||||||
text.text = message.trim()
|
|
||||||
|
|
||||||
|
// custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
|
||||||
val toast = Toast(act)
|
val toast = Toast(act)
|
||||||
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
|
|
||||||
toast.duration = duration ?: Toast.LENGTH_SHORT
|
toast.duration = duration ?: Toast.LENGTH_SHORT
|
||||||
toast.view = layout
|
toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
|
||||||
//https://github.com/PureWriter/ToastCompat
|
toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
|
||||||
toast.show()
|
|
||||||
currentToast = toast
|
currentToast = toast
|
||||||
|
toast.show()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
@ -138,22 +202,25 @@ object CommonActivity {
|
||||||
setLocale(this, localeCode)
|
setLocale(this, localeCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(act: ComponentActivity?) {
|
fun init(act: Activity) {
|
||||||
if (act == null) return
|
setActivityInstance(act)
|
||||||
|
|
||||||
|
val componentActivity = activity as? ComponentActivity ?: return
|
||||||
|
|
||||||
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
|
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
|
||||||
//https://developer.android.com/guide/topics/ui/picture-in-picture
|
//https://developer.android.com/guide/topics/ui/picture-in-picture
|
||||||
canShowPipMode =
|
canShowPipMode =
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
|
||||||
act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
|
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
|
||||||
act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
|
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
|
||||||
|
|
||||||
act.updateLocale()
|
componentActivity.updateLocale()
|
||||||
act.updateTv()
|
componentActivity.updateTv()
|
||||||
NewPipe.init(DownloaderTestImpl.getInstance())
|
NewPipe.init(DownloaderTestImpl.getInstance())
|
||||||
|
|
||||||
for (resumeApp in resumeApps) {
|
for (resumeApp in resumeApps) {
|
||||||
resumeApp.launcher =
|
resumeApp.launcher =
|
||||||
act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
val resultCode = result.resultCode
|
val resultCode = result.resultCode
|
||||||
val data = result.data
|
val data = result.data
|
||||||
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
|
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
|
||||||
|
|
@ -170,11 +237,11 @@ object CommonActivity {
|
||||||
// Ask for notification permissions on Android 13
|
// Ask for notification permissions on Android 13
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
act,
|
componentActivity,
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
) != PackageManager.PERMISSION_GRANTED
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
val requestPermissionLauncher = act.registerForActivityResult(
|
val requestPermissionLauncher = componentActivity.registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { isGranted: Boolean ->
|
) { isGranted: Boolean ->
|
||||||
Log.d(TAG, "Notification permission: $isGranted")
|
Log.d(TAG, "Notification permission: $isGranted")
|
||||||
|
|
@ -210,30 +277,57 @@ object CommonActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateTheme(act: Activity) {
|
||||||
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
|
||||||
|
if (settingsManager
|
||||||
|
.getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
|
||||||
|
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
loadThemes(act)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapSystemTheme(act: Activity): Int {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val currentNightMode =
|
||||||
|
act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||||
|
return when (currentNightMode) {
|
||||||
|
Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
|
||||||
|
else -> R.style.AppTheme // Night mode is active, we're using dark theme
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return R.style.AppTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadThemes(act: Activity?) {
|
fun loadThemes(act: Activity?) {
|
||||||
if (act == null) return
|
if (act == null) return
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
|
||||||
|
|
||||||
val currentTheme =
|
val currentTheme =
|
||||||
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
|
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
|
||||||
|
"System" -> mapSystemTheme(act)
|
||||||
"Black" -> R.style.AppTheme
|
"Black" -> R.style.AppTheme
|
||||||
"Light" -> R.style.LightMode
|
"Light" -> R.style.LightMode
|
||||||
"Amoled" -> R.style.AmoledMode
|
"Amoled" -> R.style.AmoledMode
|
||||||
"AmoledLight" -> R.style.AmoledModeLight
|
"AmoledLight" -> R.style.AmoledModeLight
|
||||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
R.style.MonetMode else R.style.AppTheme
|
R.style.MonetMode else R.style.AppTheme
|
||||||
|
|
||||||
else -> R.style.AppTheme
|
else -> R.style.AppTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentOverlayTheme =
|
val currentOverlayTheme =
|
||||||
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
|
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
|
||||||
"Normal" -> R.style.OverlayPrimaryColorNormal
|
"Normal" -> R.style.OverlayPrimaryColorNormal
|
||||||
|
"DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
|
||||||
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
|
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
|
||||||
|
"Orange" -> R.style.OverlayPrimaryColorOrange
|
||||||
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
|
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
|
||||||
"Maroon" -> R.style.OverlayPrimaryColorMaroon
|
"Maroon" -> R.style.OverlayPrimaryColorMaroon
|
||||||
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
|
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
|
||||||
"Grey" -> R.style.OverlayPrimaryColorGrey
|
"Grey" -> R.style.OverlayPrimaryColorGrey
|
||||||
"White" -> R.style.OverlayPrimaryColorWhite
|
"White" -> R.style.OverlayPrimaryColorWhite
|
||||||
|
"CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
|
||||||
"Brown" -> R.style.OverlayPrimaryColorBrown
|
"Brown" -> R.style.OverlayPrimaryColorBrown
|
||||||
"Purple" -> R.style.OverlayPrimaryColorPurple
|
"Purple" -> R.style.OverlayPrimaryColorPurple
|
||||||
"Green" -> R.style.OverlayPrimaryColorGreen
|
"Green" -> R.style.OverlayPrimaryColorGreen
|
||||||
|
|
@ -242,10 +336,13 @@ object CommonActivity {
|
||||||
"Banana" -> R.style.OverlayPrimaryColorBanana
|
"Banana" -> R.style.OverlayPrimaryColorBanana
|
||||||
"Party" -> R.style.OverlayPrimaryColorParty
|
"Party" -> R.style.OverlayPrimaryColorParty
|
||||||
"Pink" -> R.style.OverlayPrimaryColorPink
|
"Pink" -> R.style.OverlayPrimaryColorPink
|
||||||
|
"Lavender" -> R.style.OverlayPrimaryColorLavender
|
||||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
|
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
|
||||||
|
|
||||||
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
|
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
|
||||||
|
|
||||||
else -> R.style.OverlayPrimaryColorNormal
|
else -> R.style.OverlayPrimaryColorNormal
|
||||||
}
|
}
|
||||||
act.theme.applyStyle(currentTheme, true)
|
act.theme.applyStyle(currentTheme, true)
|
||||||
|
|
@ -257,101 +354,179 @@ object CommonActivity {
|
||||||
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
|
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getNextFocus(
|
/** because we want closes find, aka when multiple have the same id, we go to parent
|
||||||
act: Activity?,
|
until the correct one is found */
|
||||||
|
private fun localLook(from: View, id: Int): View? {
|
||||||
|
if (id == NO_ID) return null
|
||||||
|
var currentLook: View = from
|
||||||
|
// limit to 15 look depth
|
||||||
|
for (i in 0..15) {
|
||||||
|
currentLook.findViewById<View?>(id)?.let { return it }
|
||||||
|
currentLook = (currentLook.parent as? View) ?: break
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
/*var currentLook: View = view
|
||||||
|
while (true) {
|
||||||
|
val tmpNext = currentLook.findViewById<View?>(nextId)
|
||||||
|
if (tmpNext != null) {
|
||||||
|
next = tmpNext
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentLook = currentLook.parent as? View ?: break
|
||||||
|
}*/
|
||||||
|
|
||||||
|
private fun View.hasContent(): Boolean {
|
||||||
|
return isShown && when (this) {
|
||||||
|
//is RecyclerView -> this.childCount > 0
|
||||||
|
is ViewGroup -> this.childCount > 0
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
|
||||||
|
fun continueGetNextFocus(
|
||||||
|
root: Any?,
|
||||||
|
view: View,
|
||||||
|
direction: FocusDirection,
|
||||||
|
nextId: Int,
|
||||||
|
depth: Int = 0
|
||||||
|
): View? {
|
||||||
|
if (nextId == NO_ID) return null
|
||||||
|
|
||||||
|
// do an initial search for the view, in case the localLook is too deep we can use this as
|
||||||
|
// an early break and backup view
|
||||||
|
var next =
|
||||||
|
when (root) {
|
||||||
|
is Activity -> root.findViewById(nextId)
|
||||||
|
is View -> root.rootView.findViewById<View?>(nextId)
|
||||||
|
else -> null
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
next = localLook(view, nextId) ?: next
|
||||||
|
val shown = next.hasContent()
|
||||||
|
|
||||||
|
// if cant focus but visible then break and let android decide
|
||||||
|
// the exception if is the view is a parent and has children that wants focus
|
||||||
|
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
|
||||||
|
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
|
||||||
|
} ?: false
|
||||||
|
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
|
||||||
|
|
||||||
|
// if not shown then continue because we will "skip" over views to get to a replacement
|
||||||
|
if (!shown) {
|
||||||
|
// we don't want a while true loop, so we let android decide if we find a recursive view
|
||||||
|
if (next == view) return null
|
||||||
|
return getNextFocus(root, next, direction, depth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
(when (next) {
|
||||||
|
is ChipGroup -> {
|
||||||
|
next.children.firstOrNull { it.isFocusable && it.isShown }
|
||||||
|
}
|
||||||
|
|
||||||
|
is NavigationRailView -> {
|
||||||
|
next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
})?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing wrong with the view found, return it
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** recursively looks for a next focus up to a depth of 10,
|
||||||
|
* this is used to override the normal shit focus system
|
||||||
|
* because this application has a lot of invisible views that messes with some tv devices*/
|
||||||
|
fun getNextFocus(
|
||||||
|
root: Any?,
|
||||||
view: View?,
|
view: View?,
|
||||||
direction: FocusDirection,
|
direction: FocusDirection,
|
||||||
depth: Int = 0
|
depth: Int = 0
|
||||||
): Int? {
|
): View? {
|
||||||
if (view == null || depth >= 10 || act == null) {
|
// if input is invalid let android decide + depth test to not crash if loop is found
|
||||||
|
if (view == null || depth >= 10 || root == null) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val nextId = when (direction) {
|
var nextId = when (direction) {
|
||||||
FocusDirection.Left -> {
|
FocusDirection.Start -> {
|
||||||
view.nextFocusLeftId
|
if (view.isRtl())
|
||||||
|
view.nextFocusRightId
|
||||||
|
else
|
||||||
|
view.nextFocusLeftId
|
||||||
}
|
}
|
||||||
|
|
||||||
FocusDirection.Up -> {
|
FocusDirection.Up -> {
|
||||||
view.nextFocusUpId
|
view.nextFocusUpId
|
||||||
}
|
}
|
||||||
FocusDirection.Right -> {
|
|
||||||
view.nextFocusRightId
|
FocusDirection.End -> {
|
||||||
|
if (view.isRtl())
|
||||||
|
view.nextFocusLeftId
|
||||||
|
else
|
||||||
|
view.nextFocusRightId
|
||||||
}
|
}
|
||||||
|
|
||||||
FocusDirection.Down -> {
|
FocusDirection.Down -> {
|
||||||
view.nextFocusDownId
|
view.nextFocusDownId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (nextId != -1) {
|
if (nextId == NO_ID) {
|
||||||
val next = act.findViewById<View?>(nextId)
|
// if not specified then use forward id
|
||||||
//println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" )
|
nextId = view.nextFocusForwardId
|
||||||
|
// if view is still not found to next focus then return and let android decide
|
||||||
if (next?.isShown == false) {
|
if (nextId == NO_ID)
|
||||||
getNextFocus(act, next, direction, depth + 1)
|
return null
|
||||||
} else {
|
|
||||||
if (depth == 0) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
nextId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
return continueGetNextFocus(root, view, direction, nextId, depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class FocusDirection {
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Up,
|
|
||||||
Down,
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
|
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
|
||||||
//println("Keycode: $keyCode")
|
|
||||||
//showToast(
|
|
||||||
// this,
|
|
||||||
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
|
||||||
// Toast.LENGTH_LONG
|
|
||||||
//)
|
|
||||||
|
|
||||||
// Tested keycodes on remote:
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_REWIND
|
|
||||||
// KeyEvent.KEYCODE_MENU
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_NEXT
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
|
||||||
// KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
|
|
||||||
|
|
||||||
// 149 keycode_numpad 5
|
// 149 keycode_numpad 5
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
PlayerEventType.SeekForward
|
PlayerEventType.SeekForward
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
PlayerEventType.SeekBack
|
PlayerEventType.SeekBack
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
|
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
|
||||||
PlayerEventType.NextEpisode
|
PlayerEventType.NextEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
|
||||||
PlayerEventType.PrevEpisode
|
PlayerEventType.PrevEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
PlayerEventType.Pause
|
PlayerEventType.Pause
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
||||||
PlayerEventType.Play
|
PlayerEventType.Play
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
||||||
PlayerEventType.Lock
|
PlayerEventType.Lock
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
||||||
PlayerEventType.ToggleHide
|
PlayerEventType.ToggleHide
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
||||||
PlayerEventType.ToggleMute
|
PlayerEventType.ToggleMute
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
||||||
PlayerEventType.ShowMirrors
|
PlayerEventType.ShowMirrors
|
||||||
}
|
}
|
||||||
|
|
@ -359,21 +534,27 @@ object CommonActivity {
|
||||||
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
||||||
PlayerEventType.SearchSubtitlesOnline
|
PlayerEventType.SearchSubtitlesOnline
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
||||||
PlayerEventType.ShowSpeed
|
PlayerEventType.ShowSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
||||||
PlayerEventType.Resize
|
PlayerEventType.Resize
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
||||||
PlayerEventType.SkipOp
|
PlayerEventType.SkipOp
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
||||||
PlayerEventType.SkipCurrentChapter
|
PlayerEventType.SkipCurrentChapter
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
||||||
PlayerEventType.PlayPauseToggle
|
PlayerEventType.PlayPauseToggle
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}?.let { playerEvent ->
|
}?.let { playerEvent ->
|
||||||
playerEventListener?.invoke(playerEvent)
|
playerEventListener?.invoke(playerEvent)
|
||||||
|
|
@ -386,64 +567,64 @@ object CommonActivity {
|
||||||
//}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** overrides focus and custom key events */
|
||||||
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
|
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
|
||||||
if (act == null) return null
|
if (act == null) return null
|
||||||
|
val currentFocus = act.currentFocus
|
||||||
|
|
||||||
event?.keyCode?.let { keyCode ->
|
event?.keyCode?.let { keyCode ->
|
||||||
when (event.action) {
|
if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
|
||||||
KeyEvent.ACTION_DOWN -> {
|
val nextView = when (keyCode) {
|
||||||
if (act.currentFocus != null) {
|
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
|
||||||
val next = when (keyCode) {
|
act,
|
||||||
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
|
currentFocus,
|
||||||
act,
|
FocusDirection.Start
|
||||||
act.currentFocus,
|
)
|
||||||
FocusDirection.Left
|
|
||||||
)
|
|
||||||
KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
|
|
||||||
act,
|
|
||||||
act.currentFocus,
|
|
||||||
FocusDirection.Right
|
|
||||||
)
|
|
||||||
KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
|
|
||||||
act,
|
|
||||||
act.currentFocus,
|
|
||||||
FocusDirection.Up
|
|
||||||
)
|
|
||||||
KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
|
|
||||||
act,
|
|
||||||
act.currentFocus,
|
|
||||||
FocusDirection.Down
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> null
|
KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
|
||||||
}
|
act,
|
||||||
|
currentFocus,
|
||||||
|
FocusDirection.End
|
||||||
|
)
|
||||||
|
|
||||||
if (next != null && next != -1) {
|
KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
|
||||||
val nextView = act.findViewById<View?>(next)
|
act,
|
||||||
if (nextView != null) {
|
currentFocus,
|
||||||
nextView.requestFocus()
|
FocusDirection.Up
|
||||||
keyEventListener?.invoke(Pair(event, true))
|
)
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (keyCode) {
|
KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
|
||||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
act,
|
||||||
if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) {
|
currentFocus,
|
||||||
UIHelper.showInputMethod(act.currentFocus?.findFocus())
|
FocusDirection.Down
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
else -> null
|
||||||
}
|
|
||||||
//println("Keycode: $keyCode")
|
|
||||||
//showToast(
|
|
||||||
// this,
|
|
||||||
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
|
||||||
// Toast.LENGTH_LONG
|
|
||||||
//)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// println("NEXT FOCUS : $nextView")
|
||||||
|
if (nextView != null) {
|
||||||
|
nextView.requestFocus()
|
||||||
|
keyEventListener?.invoke(Pair(event, true))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
||||||
|
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||||
|
) {
|
||||||
|
UIHelper.showInputMethod(act.currentFocus?.findFocus())
|
||||||
|
}
|
||||||
|
|
||||||
|
//println("Keycode: $keyCode")
|
||||||
|
//showToast(
|
||||||
|
// this,
|
||||||
|
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
|
||||||
|
// Toast.LENGTH_LONG
|
||||||
|
//)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if someone else want to override the focus then don't handle the event as it is already
|
||||||
|
// consumed. used in video player
|
||||||
if (keyEventListener?.invoke(Pair(event, false)) == true) {
|
if (keyEventListener?.invoke(Pair(event, false)) == true) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
|
||||||
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.schabi.newpipe.extractor.downloader.Downloader
|
import org.schabi.newpipe.extractor.downloader.Downloader
|
||||||
import org.schabi.newpipe.extractor.downloader.Request
|
import org.schabi.newpipe.extractor.downloader.Request
|
||||||
import org.schabi.newpipe.extractor.downloader.Response
|
import org.schabi.newpipe.extractor.downloader.Response
|
||||||
|
|
@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
|
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
|
||||||
private val client: OkHttpClient
|
private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
|
||||||
override fun execute(request: Request): Response {
|
override fun execute(request: Request): Response {
|
||||||
val httpMethod: String = request.httpMethod()
|
val httpMethod: String = request.httpMethod()
|
||||||
val url: String = request.url()
|
val url: String = request.url()
|
||||||
|
|
@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
|
||||||
val dataToSend: ByteArray? = request.dataToSend()
|
val dataToSend: ByteArray? = request.dataToSend()
|
||||||
var requestBody: RequestBody? = null
|
var requestBody: RequestBody? = null
|
||||||
if (dataToSend != null) {
|
if (dataToSend != null) {
|
||||||
requestBody = RequestBody.create(null, dataToSend)
|
requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
|
||||||
}
|
}
|
||||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||||
.method(httpMethod, requestBody).url(url)
|
.method(httpMethod, requestBody).url(url)
|
||||||
|
|
@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val USER_AGENT =
|
private const val USER_AGENT =
|
||||||
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||||
private var instance: DownloaderTestImpl? = null
|
private var instance: DownloaderTestImpl? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
client = builder.readTimeout(30, TimeUnit.SECONDS).build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.base64Decode
|
|
||||||
import com.lagradost.cloudstream3.utils.*
|
|
||||||
|
|
||||||
open class Acefile : ExtractorApi() {
|
|
||||||
override val name = "Acefile"
|
|
||||||
override val mainUrl = "https://acefile.co"
|
|
||||||
override val requiresReferer = false
|
|
||||||
|
|
||||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
|
||||||
val sources = mutableListOf<ExtractorLink>()
|
|
||||||
app.get(url).document.select("script").map { script ->
|
|
||||||
if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
|
|
||||||
val data = getAndUnpack(script.data())
|
|
||||||
val id = data.substringAfter("{\"id\":\"").substringBefore("\",")
|
|
||||||
val key = data.substringAfter("var nfck=\"").substringBefore("\";")
|
|
||||||
app.get("https://acefile.co/local/$id?key=$key").text.let {
|
|
||||||
base64Decode(
|
|
||||||
it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))")
|
|
||||||
).let { res ->
|
|
||||||
sources.add(
|
|
||||||
ExtractorLink(
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
res.substringAfter("\"file\":\"").substringBefore("\","),
|
|
||||||
"$mainUrl/",
|
|
||||||
Qualities.Unknown.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.*
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class FileMoon : Filesim() {
|
|
||||||
override val mainUrl = "https://filemoon.to"
|
|
||||||
override val name = "FileMoon"
|
|
||||||
}
|
|
||||||
|
|
||||||
open class Filesim : ExtractorApi() {
|
|
||||||
override val name = "Filesim"
|
|
||||||
override val mainUrl = "https://files.im"
|
|
||||||
override val requiresReferer = false
|
|
||||||
|
|
||||||
override suspend fun getUrl(
|
|
||||||
url: String,
|
|
||||||
referer: String?,
|
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
|
||||||
callback: (ExtractorLink) -> Unit
|
|
||||||
) {
|
|
||||||
with(app.get(url).document) {
|
|
||||||
this.select("script").forEach { script ->
|
|
||||||
if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
|
|
||||||
val data = getAndUnpack(script.data())
|
|
||||||
val foundData = Regex("""sources:\[(.*?)]""").find(data)?.groupValues?.get(1) ?: return@forEach
|
|
||||||
val fixedData = foundData.replace("file:", """"file":""")
|
|
||||||
|
|
||||||
parseJson<List<ResponseSource>>("[$fixedData]").forEach {
|
|
||||||
callback.invoke(
|
|
||||||
ExtractorLink(
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
it.file,
|
|
||||||
"$mainUrl/",
|
|
||||||
Qualities.Unknown.value,
|
|
||||||
URI(it.file).path.endsWith(".m3u8")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ResponseSource(
|
|
||||||
@JsonProperty("file") val file: String,
|
|
||||||
@JsonProperty("type") val type: String?,
|
|
||||||
@JsonProperty("label") val label: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
|
||||||
|
|
||||||
open class Linkbox : ExtractorApi() {
|
|
||||||
override val name = "Linkbox"
|
|
||||||
override val mainUrl = "https://www.linkbox.to"
|
|
||||||
override val requiresReferer = true
|
|
||||||
|
|
||||||
override suspend fun getUrl(
|
|
||||||
url: String,
|
|
||||||
referer: String?,
|
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
|
||||||
callback: (ExtractorLink) -> Unit
|
|
||||||
) {
|
|
||||||
val id = Regex("""(/file/|id=)(\S+)[&/?]""").find(url)?.groupValues?.get(2)
|
|
||||||
app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe<Responses>()?.data?.rList?.map { link ->
|
|
||||||
callback.invoke(
|
|
||||||
ExtractorLink(
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
link.url,
|
|
||||||
url,
|
|
||||||
getQualityFromName(link.resolution)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RList(
|
|
||||||
@JsonProperty("url") val url: String,
|
|
||||||
@JsonProperty("resolution") val resolution: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Data(
|
|
||||||
@JsonProperty("rList") val rList: List<RList>?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Responses(
|
|
||||||
@JsonProperty("data") val data: Data?,
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
|
||||||
import com.lagradost.cloudstream3.utils.getAndUnpack
|
|
||||||
|
|
||||||
open class Mp4Upload : ExtractorApi() {
|
|
||||||
override var name = "Mp4Upload"
|
|
||||||
override var mainUrl = "https://www.mp4upload.com"
|
|
||||||
private val srcRegex = Regex("""player\.src\("(.*?)"""")
|
|
||||||
override val requiresReferer = true
|
|
||||||
|
|
||||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
|
||||||
with(app.get(url)) {
|
|
||||||
getAndUnpack(this.text).let { unpackedText ->
|
|
||||||
val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
|
|
||||||
srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
|
|
||||||
return listOf(
|
|
||||||
ExtractorLink(
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
link,
|
|
||||||
url,
|
|
||||||
quality ?: Qualities.Unknown.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.lagradost.cloudstream3.utils.*
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
|
||||||
|
|
||||||
data class DataOptionsJson (
|
|
||||||
@JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(),
|
|
||||||
)
|
|
||||||
data class Flashvars (
|
|
||||||
@JsonProperty("metadata") var metadata : String? = null,
|
|
||||||
@JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MetadataOkru (
|
|
||||||
@JsonProperty("videos") var videos: ArrayList<Videos> = arrayListOf(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Videos (
|
|
||||||
@JsonProperty("name") var name : String,
|
|
||||||
@JsonProperty("url") var url : String,
|
|
||||||
@JsonProperty("seekSchema") var seekSchema : Int? = null,
|
|
||||||
@JsonProperty("disallowed") var disallowed : Boolean? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class OkRuHttps: OkRu(){
|
|
||||||
override var mainUrl = "https://ok.ru"
|
|
||||||
}
|
|
||||||
|
|
||||||
open class OkRu : ExtractorApi() {
|
|
||||||
override var name = "Okru"
|
|
||||||
override var mainUrl = "http://ok.ru"
|
|
||||||
override val requiresReferer = false
|
|
||||||
|
|
||||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
|
|
||||||
val doc = app.get(url).document
|
|
||||||
val sources = ArrayList<ExtractorLink>()
|
|
||||||
val datajson = doc.select("div[data-options]").attr("data-options")
|
|
||||||
if (datajson.isNotBlank()) {
|
|
||||||
val main = parseJson<DataOptionsJson>(datajson)
|
|
||||||
val metadatajson = parseJson<MetadataOkru>(main.flashvars?.metadata!!)
|
|
||||||
val servers = metadatajson.videos
|
|
||||||
servers.forEach {
|
|
||||||
val quality = it.name.uppercase()
|
|
||||||
.replace("MOBILE","144p")
|
|
||||||
.replace("LOWEST","240p")
|
|
||||||
.replace("LOW","360p")
|
|
||||||
.replace("SD","480p")
|
|
||||||
.replace("HD","720p")
|
|
||||||
.replace("FULL","1080p")
|
|
||||||
.replace("QUAD","1440p")
|
|
||||||
.replace("ULTRA","4k")
|
|
||||||
val extractedurl = it.url.replace("\\\\u0026", "&")
|
|
||||||
sources.add(ExtractorLink(
|
|
||||||
name,
|
|
||||||
name = this.name,
|
|
||||||
extractedurl,
|
|
||||||
url,
|
|
||||||
getQualityFromName(quality),
|
|
||||||
isM3u8 = false
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
|
||||||
import com.lagradost.cloudstream3.amap
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.*
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import java.net.URI
|
|
||||||
|
|
||||||
class VidSrcExtractor2 : VidSrcExtractor() {
|
|
||||||
override val mainUrl = "https://vidsrc.me/embed"
|
|
||||||
override suspend fun getUrl(
|
|
||||||
url: String,
|
|
||||||
referer: String?,
|
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
|
||||||
callback: (ExtractorLink) -> Unit
|
|
||||||
) {
|
|
||||||
val newUrl = url.lowercase().replace(mainUrl, super.mainUrl)
|
|
||||||
super.getUrl(newUrl, referer, subtitleCallback, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open class VidSrcExtractor : ExtractorApi() {
|
|
||||||
override val name = "VidSrc"
|
|
||||||
private val absoluteUrl = "https://v2.vidsrc.me"
|
|
||||||
override val mainUrl = "$absoluteUrl/embed"
|
|
||||||
override val requiresReferer = false
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Infinite function to validate the vidSrc pass */
|
|
||||||
suspend fun validatePass(url: String) {
|
|
||||||
val uri = URI(url)
|
|
||||||
val host = uri.host
|
|
||||||
|
|
||||||
// Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/
|
|
||||||
val referer = host.split(".").let {
|
|
||||||
val size = it.size
|
|
||||||
"https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
app.get(url, referer = referer)
|
|
||||||
delay(60_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getUrl(
|
|
||||||
url: String,
|
|
||||||
referer: String?,
|
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
|
||||||
callback: (ExtractorLink) -> Unit
|
|
||||||
) {
|
|
||||||
val iframedoc = app.get(url).document
|
|
||||||
|
|
||||||
val serverslist =
|
|
||||||
iframedoc.select("div#sources.button_content div#content div#list div").map {
|
|
||||||
val datahash = it.attr("data-hash")
|
|
||||||
if (datahash.isNotBlank()) {
|
|
||||||
val links = try {
|
|
||||||
app.get(
|
|
||||||
"$absoluteUrl/srcrcp/$datahash",
|
|
||||||
referer = "https://rcp.vidsrc.me/"
|
|
||||||
).url
|
|
||||||
} catch (e: Exception) {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
links
|
|
||||||
} else ""
|
|
||||||
}
|
|
||||||
|
|
||||||
serverslist.amap { server ->
|
|
||||||
val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/")
|
|
||||||
if (linkfixed.contains("/prorcp")) {
|
|
||||||
val srcresponse = app.get(server, referer = absoluteUrl).text
|
|
||||||
val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)")
|
|
||||||
val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap
|
|
||||||
val passRegex = Regex("""['"](.*set_pass[^"']*)""")
|
|
||||||
val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace(
|
|
||||||
Regex("""^//"""), "https://"
|
|
||||||
)
|
|
||||||
|
|
||||||
callback.invoke(
|
|
||||||
ExtractorLink(
|
|
||||||
this.name,
|
|
||||||
this.name,
|
|
||||||
srcm3u8,
|
|
||||||
"https://vidsrc.stream/",
|
|
||||||
Qualities.Unknown.value,
|
|
||||||
extractorData = pass,
|
|
||||||
isM3u8 = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
loadExtractor(linkfixed, url, subtitleCallback, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.SubtitleFile
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
|
||||||
|
|
||||||
open class Voe : ExtractorApi() {
|
|
||||||
override val name = "Voe"
|
|
||||||
override val mainUrl = "https://voe.sx"
|
|
||||||
override val requiresReferer = true
|
|
||||||
|
|
||||||
override suspend fun getUrl(
|
|
||||||
url: String,
|
|
||||||
referer: String?,
|
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
|
||||||
callback: (ExtractorLink) -> Unit
|
|
||||||
) {
|
|
||||||
val res = app.get(url, referer = referer).document
|
|
||||||
val link = res.select("script").find { it.data().contains("const sources") }?.data()
|
|
||||||
?.substringAfter("\"hls\": \"")?.substringBefore("\",")
|
|
||||||
|
|
||||||
M3u8Helper.generateM3u8(
|
|
||||||
name,
|
|
||||||
link ?: return,
|
|
||||||
"$mainUrl/",
|
|
||||||
headers = mapOf("Origin" to "$mainUrl/")
|
|
||||||
).forEach(callback)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.extractors
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
|
||||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
|
||||||
|
|
||||||
open class VoeExtractor : ExtractorApi() {
|
|
||||||
override val name: String = "Voe"
|
|
||||||
override val mainUrl: String = "https://voe.sx"
|
|
||||||
override val requiresReferer = false
|
|
||||||
|
|
||||||
private data class ResponseLinks(
|
|
||||||
@JsonProperty("hls") val hls: String?,
|
|
||||||
@JsonProperty("mp4") val mp4: String?,
|
|
||||||
@JsonProperty("video_height") val label: Int?
|
|
||||||
//val type: String // Mp4
|
|
||||||
)
|
|
||||||
|
|
||||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
|
||||||
val html = app.get(url).text
|
|
||||||
if (html.isNotBlank()) {
|
|
||||||
val src = html.substringAfter("const sources =").substringBefore(";")
|
|
||||||
// Remove last comma, it is not proper json otherwise
|
|
||||||
.replace("0,", "0")
|
|
||||||
// Make json use the proper quotes
|
|
||||||
.replace("'", "\"")
|
|
||||||
|
|
||||||
//Log.i(this.name, "Result => (src) ${src}")
|
|
||||||
parseJson<ResponseLinks?>(src)?.let { voeLink ->
|
|
||||||
//Log.i(this.name, "Result => (voeLink) ${voeLink}")
|
|
||||||
|
|
||||||
// Always defaults to the hls link, but returns the mp4 if null
|
|
||||||
val linkUrl = voeLink.hls ?: voeLink.mp4
|
|
||||||
val linkLabel = voeLink.label?.toString() ?: ""
|
|
||||||
if (!linkUrl.isNullOrEmpty()) {
|
|
||||||
return listOf(
|
|
||||||
ExtractorLink(
|
|
||||||
name = this.name,
|
|
||||||
source = this.name,
|
|
||||||
url = linkUrl,
|
|
||||||
quality = getQualityFromName(linkLabel),
|
|
||||||
referer = url,
|
|
||||||
isM3u8 = voeLink.hls != null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() {
|
||||||
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
|
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val validApis by lazy {
|
private val validApis
|
||||||
apis.filter { it.lang == this.lang && it::class.java != this::class.java }
|
get() =
|
||||||
//.distinctBy { it.uniqueId }
|
synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } }
|
||||||
}
|
//.distinctBy { it.uniqueId }
|
||||||
|
|
||||||
|
|
||||||
data class CrossMetaData(
|
data class CrossMetaData(
|
||||||
@JsonProperty("isSuccess") val isSuccess: Boolean,
|
@JsonProperty("isSuccess") val isSuccess: Boolean,
|
||||||
|
|
@ -60,7 +61,8 @@ class CrossTmdbProvider : TmdbProvider() {
|
||||||
|
|
||||||
override suspend fun load(url: String): LoadResponse? {
|
override suspend fun load(url: String): LoadResponse? {
|
||||||
val base = super.load(url)?.apply {
|
val base = super.load(url)?.apply {
|
||||||
this.recommendations = this.recommendations?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE
|
this.recommendations =
|
||||||
|
this.recommendations?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE
|
||||||
val matchName = filterName(this.name)
|
val matchName = filterName(this.name)
|
||||||
when (this) {
|
when (this) {
|
||||||
is MovieLoadResponse -> {
|
is MovieLoadResponse -> {
|
||||||
|
|
@ -98,6 +100,7 @@ class CrossTmdbProvider : TmdbProvider() {
|
||||||
this.dataUrl =
|
this.dataUrl =
|
||||||
CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson()
|
CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw ErrorLoadingException("Nothing besides movies are implemented for this provider")
|
throw ErrorLoadingException("Nothing besides movies are implemented for this provider")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.metaproviders
|
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.*
|
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId
|
|
||||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
|
|
||||||
import com.lagradost.cloudstream3.utils.SyncUtil
|
|
||||||
|
|
||||||
// wont be implemented
|
|
||||||
class MultiAnimeProvider : MainAPI() {
|
|
||||||
override var name = "MultiAnime"
|
|
||||||
override var lang = "en"
|
|
||||||
override val usesWebView = true
|
|
||||||
override val supportedTypes = setOf(TvType.Anime)
|
|
||||||
private val syncApi: SyncAPI = aniListApi
|
|
||||||
|
|
||||||
private val syncUtilType by lazy {
|
|
||||||
when (syncApi) {
|
|
||||||
is AniListApi -> "anilist"
|
|
||||||
is MALApi -> "myanimelist"
|
|
||||||
else -> throw ErrorLoadingException("Invalid Api")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val validApis by lazy {
|
|
||||||
APIHolder.apis.filter {
|
|
||||||
it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
|
|
||||||
TvType.Anime
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterName(name: String): String {
|
|
||||||
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun search(query: String): List<SearchResponse>? {
|
|
||||||
return syncApi.search(query)?.map {
|
|
||||||
AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun load(url: String): LoadResponse? {
|
|
||||||
return syncApi.getResult(url)?.let { res ->
|
|
||||||
val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).amap { url ->
|
|
||||||
validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url)
|
|
||||||
}.filterNotNull()
|
|
||||||
|
|
||||||
val type =
|
|
||||||
if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime
|
|
||||||
|
|
||||||
newAnimeLoadResponse(
|
|
||||||
res.title ?: throw ErrorLoadingException("No Title found"),
|
|
||||||
url,
|
|
||||||
type
|
|
||||||
) {
|
|
||||||
posterUrl = res.posterUrl
|
|
||||||
plot = res.synopsis
|
|
||||||
tags = res.genres
|
|
||||||
rating = res.publicScore
|
|
||||||
addTrailer(res.trailers)
|
|
||||||
addAniListId(res.id.toIntOrNull())
|
|
||||||
recommendations = res.recommendations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,15 +2,13 @@ package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.MainAPI
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
|
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
|
||||||
object SyncRedirector {
|
object SyncRedirector {
|
||||||
val syncApis = SyncApis
|
|
||||||
private val syncIds =
|
private val syncIds =
|
||||||
listOf(
|
listOf(
|
||||||
SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""),
|
SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""),
|
||||||
SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""")
|
SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""")
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun redirect(
|
suspend fun redirect(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() {
|
||||||
this.id,
|
this.id,
|
||||||
episode.episode_number,
|
episode.episode_number,
|
||||||
episode.season_number,
|
episode.season_number,
|
||||||
|
this.name ?: this.original_name,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
episode.name,
|
episode.name,
|
||||||
episode.season_number,
|
episode.season_number,
|
||||||
|
|
@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() {
|
||||||
this.id,
|
this.id,
|
||||||
episodeNum,
|
episodeNum,
|
||||||
season.season_number,
|
season.season_number,
|
||||||
|
this.name ?: this.original_name,
|
||||||
).toJson(),
|
).toJson(),
|
||||||
season = season.season_number
|
season = season.season_number
|
||||||
)
|
)
|
||||||
|
|
@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() {
|
||||||
recommendations = (this@toLoadResponse.recommendations
|
recommendations = (this@toLoadResponse.recommendations
|
||||||
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
||||||
addActors(credits?.cast?.toList().toActors())
|
addActors(credits?.cast?.toList().toActors())
|
||||||
|
|
||||||
|
contentRating = fetchContentRating(id, "US")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() {
|
||||||
recommendations = (this@toLoadResponse.recommendations
|
recommendations = (this@toLoadResponse.recommendations
|
||||||
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
|
||||||
addActors(credits?.cast?.toList().toActors())
|
addActors(credits?.cast?.toList().toActors())
|
||||||
|
|
||||||
|
contentRating = fetchContentRating(id, "US")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open suspend fun fetchContentRating(id: Int?, country: String): String? {
|
||||||
|
id ?: return null
|
||||||
|
|
||||||
|
val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results
|
||||||
|
return if (!contentRatings.isNullOrEmpty()) {
|
||||||
|
contentRatings.firstOrNull { it: ContentRating ->
|
||||||
|
it.iso_3166_1 == country
|
||||||
|
}?.rating
|
||||||
|
} else {
|
||||||
|
val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results
|
||||||
|
val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult ->
|
||||||
|
it.iso_3166_1 == country
|
||||||
|
}?.release_dates?.firstOrNull { it: ReleaseDate ->
|
||||||
|
!it.certification.isNullOrBlank()
|
||||||
|
}?.certification
|
||||||
|
|
||||||
|
certification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Possible to add recommendations and such here.
|
// Possible to add recommendations and such here.
|
||||||
override suspend fun load(url: String): LoadResponse? {
|
override suspend fun load(url: String): LoadResponse? {
|
||||||
// https://www.themoviedb.org/movie/7445-brothers
|
// https://www.themoviedb.org/movie/7445-brothers
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,471 @@
|
||||||
|
package com.lagradost.cloudstream3.metaproviders
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
|
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||||
|
import com.lagradost.cloudstream3.Actor
|
||||||
|
import com.lagradost.cloudstream3.ActorData
|
||||||
|
import com.lagradost.cloudstream3.Episode
|
||||||
|
import com.lagradost.cloudstream3.HomePageResponse
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
||||||
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
|
import com.lagradost.cloudstream3.MainPageRequest
|
||||||
|
import com.lagradost.cloudstream3.NextAiring
|
||||||
|
import com.lagradost.cloudstream3.ProviderType
|
||||||
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.ShowStatus
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.addDate
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.base64Decode
|
||||||
|
import com.lagradost.cloudstream3.mainPageOf
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.newHomePageResponse
|
||||||
|
import com.lagradost.cloudstream3.newMovieLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.newMovieSearchResponse
|
||||||
|
import com.lagradost.cloudstream3.newTvSeriesLoadResponse
|
||||||
|
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
open class TraktProvider : MainAPI() {
|
||||||
|
override var name = "Trakt"
|
||||||
|
override val hasMainPage = true
|
||||||
|
override val providerType = ProviderType.MetaProvider
|
||||||
|
override val supportedTypes = setOf(
|
||||||
|
TvType.Movie,
|
||||||
|
TvType.TvSeries,
|
||||||
|
TvType.Anime,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val traktClientId =
|
||||||
|
base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
|
||||||
|
private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
|
||||||
|
|
||||||
|
override val mainPage = mainPageOf(
|
||||||
|
"$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
|
||||||
|
"$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time
|
||||||
|
"$traktApiUrl/shows/trending" to "Trending Shows", //Most watched Shows right now
|
||||||
|
"$traktApiUrl/shows/popular" to "Popular Shows", //The most popular Shows for all time
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
|
||||||
|
|
||||||
|
val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page")
|
||||||
|
|
||||||
|
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
|
||||||
|
element.toSearchResponse()
|
||||||
|
}
|
||||||
|
return newHomePageResponse(request.name, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MediaDetails.toSearchResponse(): SearchResponse {
|
||||||
|
|
||||||
|
val media = this.media ?: this
|
||||||
|
val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
|
||||||
|
val poster = media.images?.poster?.firstOrNull()
|
||||||
|
|
||||||
|
if (mediaType == TvType.Movie) {
|
||||||
|
return newMovieSearchResponse(
|
||||||
|
name = media.title!!,
|
||||||
|
url = Data(
|
||||||
|
type = mediaType,
|
||||||
|
mediaDetails = media,
|
||||||
|
).toJson(),
|
||||||
|
type = TvType.Movie,
|
||||||
|
) {
|
||||||
|
posterUrl = fixPath(poster)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return newTvSeriesSearchResponse(
|
||||||
|
name = media.title!!,
|
||||||
|
url = Data(
|
||||||
|
type = mediaType,
|
||||||
|
mediaDetails = media,
|
||||||
|
).toJson(),
|
||||||
|
type = TvType.TvSeries,
|
||||||
|
) {
|
||||||
|
this.posterUrl = fixPath(poster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: String): List<SearchResponse>? {
|
||||||
|
val apiResponse =
|
||||||
|
getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query")
|
||||||
|
|
||||||
|
val results = parseJson<List<MediaDetails>>(apiResponse).map { element ->
|
||||||
|
element.toSearchResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(url: String): LoadResponse {
|
||||||
|
|
||||||
|
val data = parseJson<Data>(url)
|
||||||
|
val mediaDetails = data.mediaDetails
|
||||||
|
val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
|
||||||
|
|
||||||
|
val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
|
||||||
|
val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
|
||||||
|
|
||||||
|
val resActor =
|
||||||
|
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
|
||||||
|
|
||||||
|
val actors = parseJson<People>(resActor).cast?.map {
|
||||||
|
ActorData(
|
||||||
|
Actor(
|
||||||
|
name = it.person?.name!!,
|
||||||
|
image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
|
||||||
|
),
|
||||||
|
roleString = it.character
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resRelated =
|
||||||
|
getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
|
||||||
|
|
||||||
|
val relatedMedia = parseJson<List<MediaDetails>>(resRelated).map { it.toSearchResponse() }
|
||||||
|
|
||||||
|
val isCartoon =
|
||||||
|
mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
|
||||||
|
val isAnime =
|
||||||
|
isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja")
|
||||||
|
val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
|
||||||
|
val isBollywood = mediaDetails?.country == "in"
|
||||||
|
|
||||||
|
if (data.type == TvType.Movie) {
|
||||||
|
|
||||||
|
val linkData = LinkData(
|
||||||
|
id = mediaDetails?.ids?.tmdb,
|
||||||
|
traktId = mediaDetails?.ids?.trakt,
|
||||||
|
traktSlug = mediaDetails?.ids?.slug,
|
||||||
|
tmdbId = mediaDetails?.ids?.tmdb,
|
||||||
|
imdbId = mediaDetails?.ids?.imdb.toString(),
|
||||||
|
tvdbId = mediaDetails?.ids?.tvdb,
|
||||||
|
tvrageId = mediaDetails?.ids?.tvrage,
|
||||||
|
type = data.type.toString(),
|
||||||
|
title = mediaDetails?.title,
|
||||||
|
year = mediaDetails?.year,
|
||||||
|
orgTitle = mediaDetails?.title,
|
||||||
|
isAnime = isAnime,
|
||||||
|
//jpTitle = later if needed as it requires another network request,
|
||||||
|
airedDate = mediaDetails?.released
|
||||||
|
?: mediaDetails?.firstAired,
|
||||||
|
isAsian = isAsian,
|
||||||
|
isBollywood = isBollywood,
|
||||||
|
).toJson()
|
||||||
|
|
||||||
|
return newMovieLoadResponse(
|
||||||
|
name = mediaDetails?.title!!,
|
||||||
|
url = data.toJson(),
|
||||||
|
dataUrl = linkData.toJson(),
|
||||||
|
type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
|
||||||
|
) {
|
||||||
|
this.name = mediaDetails.title
|
||||||
|
this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
|
||||||
|
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
|
||||||
|
this.year = mediaDetails.year
|
||||||
|
this.plot = mediaDetails.overview
|
||||||
|
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
|
||||||
|
this.tags = mediaDetails.genres
|
||||||
|
this.duration = mediaDetails.runtime
|
||||||
|
this.recommendations = relatedMedia
|
||||||
|
this.actors = actors
|
||||||
|
this.comingSoon = isUpcoming(mediaDetails.released)
|
||||||
|
//posterHeaders
|
||||||
|
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
|
||||||
|
this.contentRating = mediaDetails.certification
|
||||||
|
addTrailer(mediaDetails.trailer)
|
||||||
|
addImdbId(mediaDetails.ids?.imdb)
|
||||||
|
addTMDbId(mediaDetails.ids?.tmdb.toString())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
val resSeasons =
|
||||||
|
getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
|
||||||
|
val episodes = mutableListOf<Episode>()
|
||||||
|
val seasons = parseJson<List<Seasons>>(resSeasons)
|
||||||
|
var nextAir: NextAiring? = null
|
||||||
|
|
||||||
|
seasons.forEach { season ->
|
||||||
|
|
||||||
|
season.episodes?.map { episode ->
|
||||||
|
|
||||||
|
val linkData = LinkData(
|
||||||
|
id = mediaDetails?.ids?.tmdb,
|
||||||
|
traktId = mediaDetails?.ids?.trakt,
|
||||||
|
traktSlug = mediaDetails?.ids?.slug,
|
||||||
|
tmdbId = mediaDetails?.ids?.tmdb,
|
||||||
|
imdbId = mediaDetails?.ids?.imdb.toString(),
|
||||||
|
tvdbId = mediaDetails?.ids?.tvdb,
|
||||||
|
tvrageId = mediaDetails?.ids?.tvrage,
|
||||||
|
type = data.type.toString(),
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.number,
|
||||||
|
title = mediaDetails?.title,
|
||||||
|
year = mediaDetails?.year,
|
||||||
|
orgTitle = mediaDetails?.title,
|
||||||
|
isAnime = isAnime,
|
||||||
|
airedYear = mediaDetails?.year,
|
||||||
|
lastSeason = seasons.size,
|
||||||
|
epsTitle = episode.title,
|
||||||
|
//jpTitle = later if needed as it requires another network request,
|
||||||
|
date = episode.firstAired,
|
||||||
|
airedDate = episode.firstAired,
|
||||||
|
isAsian = isAsian,
|
||||||
|
isBollywood = isBollywood,
|
||||||
|
isCartoon = isCartoon
|
||||||
|
).toJson()
|
||||||
|
|
||||||
|
episodes.add(
|
||||||
|
Episode(
|
||||||
|
data = linkData.toJson(),
|
||||||
|
name = episode.title,
|
||||||
|
season = episode.season,
|
||||||
|
episode = episode.number,
|
||||||
|
posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
|
||||||
|
rating = episode.rating?.times(10)?.roundToInt(),
|
||||||
|
description = episode.overview,
|
||||||
|
runTime = episode.runtime
|
||||||
|
).apply {
|
||||||
|
this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
|
||||||
|
if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
|
||||||
|
nextAir = NextAiring(
|
||||||
|
episode = this.episode!!,
|
||||||
|
unixTime = this.date!!.div(1000L),
|
||||||
|
season = if (this.season == 1) null else this.season,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTvSeriesLoadResponse(
|
||||||
|
name = mediaDetails?.title!!,
|
||||||
|
url = data.toJson(),
|
||||||
|
type = if (isAnime) TvType.Anime else TvType.TvSeries,
|
||||||
|
episodes = episodes
|
||||||
|
) {
|
||||||
|
this.name = mediaDetails.title
|
||||||
|
this.type = if (isAnime) TvType.Anime else TvType.TvSeries
|
||||||
|
this.episodes = episodes
|
||||||
|
this.posterUrl = getOriginalWidthImageUrl(posterUrl)
|
||||||
|
this.year = mediaDetails.year
|
||||||
|
this.plot = mediaDetails.overview
|
||||||
|
this.showStatus = getStatus(mediaDetails.status)
|
||||||
|
this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
|
||||||
|
this.tags = mediaDetails.genres
|
||||||
|
this.duration = mediaDetails.runtime
|
||||||
|
this.recommendations = relatedMedia
|
||||||
|
this.actors = actors
|
||||||
|
this.comingSoon = isUpcoming(mediaDetails.released)
|
||||||
|
//posterHeaders
|
||||||
|
this.nextAiring = nextAir
|
||||||
|
this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
|
||||||
|
this.contentRating = mediaDetails.certification
|
||||||
|
addTrailer(mediaDetails.trailer)
|
||||||
|
addImdbId(mediaDetails.ids?.imdb)
|
||||||
|
addTMDbId(mediaDetails.ids?.tmdb.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getApi(url: String): String {
|
||||||
|
return app.get(
|
||||||
|
url = url,
|
||||||
|
headers = mapOf(
|
||||||
|
"Content-Type" to "application/json",
|
||||||
|
"trakt-api-version" to "2",
|
||||||
|
"trakt-api-key" to traktClientId,
|
||||||
|
)
|
||||||
|
).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUpcoming(dateString: String?): Boolean {
|
||||||
|
return try {
|
||||||
|
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
|
||||||
|
val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
|
||||||
|
unixTimeMS < dateTime
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStatus(t: String?): ShowStatus {
|
||||||
|
return when (t) {
|
||||||
|
"returning series" -> ShowStatus.Ongoing
|
||||||
|
"continuing" -> ShowStatus.Ongoing
|
||||||
|
else -> ShowStatus.Completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fixPath(url: String?): String? {
|
||||||
|
url ?: return null
|
||||||
|
return "https://$url"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWidthImageUrl(path: String?, width: String): String? {
|
||||||
|
if (path == null) return null
|
||||||
|
if (!path.contains("image.tmdb.org")) return fixPath(path)
|
||||||
|
val fileName = Uri.parse(path).lastPathSegment ?: return null
|
||||||
|
return "https://image.tmdb.org/t/p/${width}/${fileName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOriginalWidthImageUrl(path: String?): String? {
|
||||||
|
if (path == null) return null
|
||||||
|
if (!path.contains("image.tmdb.org")) return fixPath(path)
|
||||||
|
return getWidthImageUrl(path, "original")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Data(
|
||||||
|
val type: TvType? = null,
|
||||||
|
val mediaDetails: MediaDetails? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MediaDetails(
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("year") val year: Int? = null,
|
||||||
|
@JsonProperty("ids") val ids: Ids? = null,
|
||||||
|
@JsonProperty("tagline") val tagline: String? = null,
|
||||||
|
@JsonProperty("overview") val overview: String? = null,
|
||||||
|
@JsonProperty("released") val released: String? = null,
|
||||||
|
@JsonProperty("runtime") val runtime: Int? = null,
|
||||||
|
@JsonProperty("country") val country: String? = null,
|
||||||
|
@JsonProperty("updatedAt") val updatedAt: String? = null,
|
||||||
|
@JsonProperty("trailer") val trailer: String? = null,
|
||||||
|
@JsonProperty("homepage") val homepage: String? = null,
|
||||||
|
@JsonProperty("status") val status: String? = null,
|
||||||
|
@JsonProperty("rating") val rating: Double? = null,
|
||||||
|
@JsonProperty("votes") val votes: Long? = null,
|
||||||
|
@JsonProperty("comment_count") val commentCount: Long? = null,
|
||||||
|
@JsonProperty("language") val language: String? = null,
|
||||||
|
@JsonProperty("languages") val languages: List<String>? = null,
|
||||||
|
@JsonProperty("available_translations") val availableTranslations: List<String>? = null,
|
||||||
|
@JsonProperty("genres") val genres: List<String>? = null,
|
||||||
|
@JsonProperty("certification") val certification: String? = null,
|
||||||
|
@JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
|
||||||
|
@JsonProperty("first_aired") val firstAired: String? = null,
|
||||||
|
@JsonProperty("airs") val airs: Airs? = null,
|
||||||
|
@JsonProperty("network") val network: String? = null,
|
||||||
|
@JsonProperty("images") val images: Images? = null,
|
||||||
|
@JsonProperty("movie") @JsonAlias("show") val media: MediaDetails? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Airs(
|
||||||
|
@JsonProperty("day") val day: String? = null,
|
||||||
|
@JsonProperty("time") val time: String? = null,
|
||||||
|
@JsonProperty("timezone") val timezone: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Ids(
|
||||||
|
@JsonProperty("trakt") val trakt: Int? = null,
|
||||||
|
@JsonProperty("slug") val slug: String? = null,
|
||||||
|
@JsonProperty("tvdb") val tvdb: Int? = null,
|
||||||
|
@JsonProperty("imdb") val imdb: String? = null,
|
||||||
|
@JsonProperty("tmdb") val tmdb: Int? = null,
|
||||||
|
@JsonProperty("tvrage") val tvrage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Images(
|
||||||
|
@JsonProperty("fanart") val fanart: List<String>? = null,
|
||||||
|
@JsonProperty("poster") val poster: List<String>? = null,
|
||||||
|
@JsonProperty("logo") val logo: List<String>? = null,
|
||||||
|
@JsonProperty("clearart") val clearart: List<String>? = null,
|
||||||
|
@JsonProperty("banner") val banner: List<String>? = null,
|
||||||
|
@JsonProperty("thumb") val thumb: List<String>? = null,
|
||||||
|
@JsonProperty("screenshot") val screenshot: List<String>? = null,
|
||||||
|
@JsonProperty("headshot") val headshot: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class People(
|
||||||
|
@JsonProperty("cast") val cast: List<Cast>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Cast(
|
||||||
|
@JsonProperty("character") val character: String? = null,
|
||||||
|
@JsonProperty("characters") val characters: List<String>? = null,
|
||||||
|
@JsonProperty("episode_count") val episodeCount: Long? = null,
|
||||||
|
@JsonProperty("person") val person: Person? = null,
|
||||||
|
@JsonProperty("images") val images: Images? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Person(
|
||||||
|
@JsonProperty("name") val name: String? = null,
|
||||||
|
@JsonProperty("ids") val ids: Ids? = null,
|
||||||
|
@JsonProperty("images") val images: Images? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Seasons(
|
||||||
|
@JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
|
||||||
|
@JsonProperty("episode_count") val episodeCount: Int? = null,
|
||||||
|
@JsonProperty("episodes") val episodes: List<TraktEpisode>? = null,
|
||||||
|
@JsonProperty("first_aired") val firstAired: String? = null,
|
||||||
|
@JsonProperty("ids") val ids: Ids? = null,
|
||||||
|
@JsonProperty("images") val images: Images? = null,
|
||||||
|
@JsonProperty("network") val network: String? = null,
|
||||||
|
@JsonProperty("number") val number: Int? = null,
|
||||||
|
@JsonProperty("overview") val overview: String? = null,
|
||||||
|
@JsonProperty("rating") val rating: Double? = null,
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("updated_at") val updatedAt: String? = null,
|
||||||
|
@JsonProperty("votes") val votes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TraktEpisode(
|
||||||
|
@JsonProperty("available_translations") val availableTranslations: List<String>? = null,
|
||||||
|
@JsonProperty("comment_count") val commentCount: Int? = null,
|
||||||
|
@JsonProperty("episode_type") val episodeType: String? = null,
|
||||||
|
@JsonProperty("first_aired") val firstAired: String? = null,
|
||||||
|
@JsonProperty("ids") val ids: Ids? = null,
|
||||||
|
@JsonProperty("images") val images: Images? = null,
|
||||||
|
@JsonProperty("number") val number: Int? = null,
|
||||||
|
@JsonProperty("number_abs") val numberAbs: Int? = null,
|
||||||
|
@JsonProperty("overview") val overview: String? = null,
|
||||||
|
@JsonProperty("rating") val rating: Double? = null,
|
||||||
|
@JsonProperty("runtime") val runtime: Int? = null,
|
||||||
|
@JsonProperty("season") val season: Int? = null,
|
||||||
|
@JsonProperty("title") val title: String? = null,
|
||||||
|
@JsonProperty("updated_at") val updatedAt: String? = null,
|
||||||
|
@JsonProperty("votes") val votes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LinkData(
|
||||||
|
val id: Int? = null,
|
||||||
|
val traktId: Int? = null,
|
||||||
|
val traktSlug: String? = null,
|
||||||
|
val tmdbId: Int? = null,
|
||||||
|
val imdbId: String? = null,
|
||||||
|
val tvdbId: Int? = null,
|
||||||
|
val tvrageId: String? = null,
|
||||||
|
val type: String? = null,
|
||||||
|
val season: Int? = null,
|
||||||
|
val episode: Int? = null,
|
||||||
|
val aniId: String? = null,
|
||||||
|
val animeId: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val year: Int? = null,
|
||||||
|
val orgTitle: String? = null,
|
||||||
|
val isAnime: Boolean = false,
|
||||||
|
val airedYear: Int? = null,
|
||||||
|
val lastSeason: Int? = null,
|
||||||
|
val epsTitle: String? = null,
|
||||||
|
val jpTitle: String? = null,
|
||||||
|
val date: String? = null,
|
||||||
|
val airedDate: String? = null,
|
||||||
|
val isAsian: Boolean = false,
|
||||||
|
val isBollywood: Boolean = false,
|
||||||
|
val isCartoon: Boolean = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.lagradost.cloudstream3.mvvm
|
||||||
|
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
|
||||||
|
/** NOTE: Only one observer at a time per value */
|
||||||
|
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||||
|
liveData.removeObservers(this)
|
||||||
|
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NOTE: Only one observer at a time per value */
|
||||||
|
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||||
|
liveData.removeObservers(this)
|
||||||
|
liveData.observe(this) { action(it) }
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.nicehttp.Requests.Companion.await
|
import com.lagradost.nicehttp.Requests.Companion.await
|
||||||
import com.lagradost.nicehttp.cookies
|
import com.lagradost.nicehttp.cookies
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.*
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -17,6 +20,8 @@ import java.net.URI
|
||||||
class CloudflareKiller : Interceptor {
|
class CloudflareKiller : Interceptor {
|
||||||
companion object {
|
companion object {
|
||||||
const val TAG = "CloudflareKiller"
|
const val TAG = "CloudflareKiller"
|
||||||
|
private val ERROR_CODES = listOf(403, 503)
|
||||||
|
private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
|
||||||
fun parseCookieMap(cookie: String): Map<String, String> {
|
fun parseCookieMap(cookie: String): Map<String, String> {
|
||||||
return cookie.split(";").associate {
|
return cookie.split(";").associate {
|
||||||
val split = it.split("=")
|
val split = it.split("=")
|
||||||
|
|
@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
|
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val cookies = savedCookies[request.url.host]
|
|
||||||
|
|
||||||
if (cookies == null) {
|
when (val cookies = savedCookies[request.url.host]) {
|
||||||
bypassCloudflare(request)?.let {
|
null -> {
|
||||||
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
|
val response = chain.proceed(request)
|
||||||
return@runBlocking it
|
if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
|
||||||
|
return@runBlocking response
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
bypassCloudflare(request)?.let {
|
||||||
|
Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
|
||||||
|
return@runBlocking it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
return@runBlocking proceed(request, cookies)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return@runBlocking proceed(request, cookies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }
|
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
@Target(AnnotationTarget.CLASS)
|
@Target(AnnotationTarget.CLASS)
|
||||||
annotation class CloudstreamPlugin(
|
annotation class CloudstreamPlugin
|
||||||
)
|
|
||||||
|
|
@ -34,9 +34,11 @@ abstract class Plugin {
|
||||||
*/
|
*/
|
||||||
fun registerMainAPI(element: MainAPI) {
|
fun registerMainAPI(element: MainAPI) {
|
||||||
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
|
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
|
||||||
element.sourcePlugin = this.__filename
|
element.sourcePlugin = this.filename
|
||||||
// Race condition causing which would case duplicates if not for distinctBy
|
// Race condition causing which would case duplicates if not for distinctBy
|
||||||
APIHolder.allProviders.add(element)
|
synchronized(APIHolder.allProviders) {
|
||||||
|
APIHolder.allProviders.add(element)
|
||||||
|
}
|
||||||
APIHolder.addPluginMapping(element)
|
APIHolder.addPluginMapping(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,22 +48,31 @@ abstract class Plugin {
|
||||||
*/
|
*/
|
||||||
fun registerExtractorAPI(element: ExtractorApi) {
|
fun registerExtractorAPI(element: ExtractorApi) {
|
||||||
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
|
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
|
||||||
element.sourcePlugin = this.__filename
|
element.sourcePlugin = this.filename
|
||||||
extractorApis.add(element)
|
extractorApis.add(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
class Manifest {
|
class Manifest {
|
||||||
@JsonProperty("name") var name: String? = null
|
@JsonProperty("name")
|
||||||
@JsonProperty("pluginClassName") var pluginClassName: String? = null
|
var name: String? = null
|
||||||
@JsonProperty("version") var version: Int? = null
|
@JsonProperty("pluginClassName")
|
||||||
@JsonProperty("requiresResources") var requiresResources: Boolean = false
|
var pluginClassName: String? = null
|
||||||
|
@JsonProperty("version")
|
||||||
|
var version: Int? = null
|
||||||
|
@JsonProperty("requiresResources")
|
||||||
|
var requiresResources: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will contain your resources if you specified requiresResources in gradle
|
* This will contain your resources if you specified requiresResources in gradle
|
||||||
*/
|
*/
|
||||||
var resources: Resources? = null
|
var resources: Resources? = null
|
||||||
var __filename: String? = null
|
/** Full file path to the plugin. */
|
||||||
|
@Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename"))
|
||||||
|
var __filename: String?
|
||||||
|
get() = filename
|
||||||
|
set(value) {filename = value}
|
||||||
|
var filename: String? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This will add a button in the settings allowing you to add custom settings
|
* This will add a button in the settings allowing you to add custom settings
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,25 @@
|
||||||
package com.lagradost.cloudstream3.plugins
|
package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
|
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||||
|
|
@ -33,6 +35,7 @@ import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorApi
|
import com.lagradost.cloudstream3.utils.ExtractorApi
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
|
@ -136,6 +139,20 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all generated oat files which will force Android to recompile the dex extensions.
|
||||||
|
* This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
|
||||||
|
*/
|
||||||
|
fun deleteAllOatFiles(context: Context) {
|
||||||
|
File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
|
||||||
|
repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
|
||||||
|
val success = file.deleteRecursively()
|
||||||
|
Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getPluginsOnline(): Array<PluginData> {
|
fun getPluginsOnline(): Array<PluginData> {
|
||||||
return getKey(PLUGINS_KEY) ?: emptyArray()
|
return getKey(PLUGINS_KEY) ?: emptyArray()
|
||||||
}
|
}
|
||||||
|
|
@ -149,7 +166,7 @@ object PluginManager {
|
||||||
|
|
||||||
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
|
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
|
||||||
|
|
||||||
public var currentlyLoading: String? = null
|
var currentlyLoading: String? = null
|
||||||
|
|
||||||
// Maps filepath to plugin
|
// Maps filepath to plugin
|
||||||
val plugins: MutableMap<String, Plugin> =
|
val plugins: MutableMap<String, Plugin> =
|
||||||
|
|
@ -162,14 +179,18 @@ object PluginManager {
|
||||||
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
||||||
HashMap<PathClassLoader, Plugin>()
|
HashMap<PathClassLoader, Plugin>()
|
||||||
|
|
||||||
private var loadedLocalPlugins = false
|
var loadedLocalPlugins = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
var loadedOnlinePlugins = false
|
||||||
|
private set
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
private suspend fun maybeLoadPlugin(activity: Activity, file: File) {
|
private suspend fun maybeLoadPlugin(context: Context, file: File) {
|
||||||
val name = file.name
|
val name = file.name
|
||||||
if (file.extension == "zip" || file.extension == "cs3") {
|
if (file.extension == "zip" || file.extension == "cs3") {
|
||||||
loadPlugin(
|
loadPlugin(
|
||||||
activity,
|
context,
|
||||||
file,
|
file,
|
||||||
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
|
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
|
||||||
)
|
)
|
||||||
|
|
@ -199,7 +220,7 @@ object PluginManager {
|
||||||
|
|
||||||
// var allCurrentOutDatedPlugins: Set<OnlinePluginData> = emptySet()
|
// var allCurrentOutDatedPlugins: Set<OnlinePluginData> = emptySet()
|
||||||
|
|
||||||
suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean {
|
suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean {
|
||||||
return (getPluginsOnline().firstOrNull {
|
return (getPluginsOnline().firstOrNull {
|
||||||
// Most of the time the provider ends with Provider which isn't part of the api name
|
// Most of the time the provider ends with Provider which isn't part of the api name
|
||||||
it.internalName.replace("provider", "", ignoreCase = true) == apiName
|
it.internalName.replace("provider", "", ignoreCase = true) == apiName
|
||||||
|
|
@ -209,7 +230,7 @@ object PluginManager {
|
||||||
})?.let { savedData ->
|
})?.let { savedData ->
|
||||||
// OnlinePluginData(savedData, onlineData)
|
// OnlinePluginData(savedData, onlineData)
|
||||||
loadPlugin(
|
loadPlugin(
|
||||||
activity,
|
context,
|
||||||
File(savedData.filePath),
|
File(savedData.filePath),
|
||||||
savedData
|
savedData
|
||||||
)
|
)
|
||||||
|
|
@ -276,6 +297,7 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ioSafe {
|
// ioSafe {
|
||||||
|
loadedOnlinePlugins = true
|
||||||
afterPluginsLoadedEvent.invoke(false)
|
afterPluginsLoadedEvent.invoke(false)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
@ -288,7 +310,7 @@ object PluginManager {
|
||||||
* 2. Fetch all not downloaded plugins
|
* 2. Fetch all not downloaded plugins
|
||||||
* 3. Download them and reload plugins
|
* 3. Download them and reload plugins
|
||||||
**/
|
**/
|
||||||
fun downloadNotExistingPluginsAndLoad(activity: Activity) {
|
fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
|
||||||
val newDownloadPlugins = mutableListOf<String>()
|
val newDownloadPlugins = mutableListOf<String>()
|
||||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||||
|
|
@ -302,6 +324,8 @@ object PluginManager {
|
||||||
// Iterate online repos and returns not downloaded plugins
|
// Iterate online repos and returns not downloaded plugins
|
||||||
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
|
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
|
||||||
val sitePlugin = onlineData.second
|
val sitePlugin = onlineData.second
|
||||||
|
val tvtypes = sitePlugin.tvTypes ?: listOf()
|
||||||
|
|
||||||
//Don't include empty urls
|
//Don't include empty urls
|
||||||
if (sitePlugin.url.isBlank()) {
|
if (sitePlugin.url.isBlank()) {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
|
|
@ -316,22 +340,29 @@ object PluginManager {
|
||||||
return@mapNotNull null
|
return@mapNotNull null
|
||||||
}
|
}
|
||||||
|
|
||||||
//Omit lang not selected on language setting
|
//Omit non-NSFW if mode is set to NSFW only
|
||||||
val lang = sitePlugin.language ?: return@mapNotNull null
|
if (mode == AutoDownloadMode.NsfwOnly) {
|
||||||
//If set to 'universal', don't skip any language
|
if (!tvtypes.contains(TvType.NSFW.name)) {
|
||||||
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
|
return@mapNotNull null
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
//Log.i(TAG, "sitePlugin lang => $lang")
|
|
||||||
|
|
||||||
//Omit NSFW, if disabled
|
|
||||||
sitePlugin.tvTypes?.let { tvtypes ->
|
|
||||||
if (!settingsForProvider.enableAdult) {
|
|
||||||
if (tvtypes.contains(TvType.NSFW.name)) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//Omit NSFW, if disabled
|
||||||
|
if (!settingsForProvider.enableAdult) {
|
||||||
|
if (tvtypes.contains(TvType.NSFW.name)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Omit lang not selected on language setting
|
||||||
|
if (mode == AutoDownloadMode.FilterByLang) {
|
||||||
|
val lang = sitePlugin.language ?: return@mapNotNull null
|
||||||
|
//If set to 'universal', don't skip any language
|
||||||
|
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
//Log.i(TAG, "sitePlugin lang => $lang")
|
||||||
|
}
|
||||||
|
|
||||||
val savedData = PluginData(
|
val savedData = PluginData(
|
||||||
url = sitePlugin.url,
|
url = sitePlugin.url,
|
||||||
internalName = sitePlugin.internalName,
|
internalName = sitePlugin.internalName,
|
||||||
|
|
@ -371,11 +402,11 @@ object PluginManager {
|
||||||
/**
|
/**
|
||||||
* Use updateAllOnlinePluginsAndLoadThem
|
* Use updateAllOnlinePluginsAndLoadThem
|
||||||
* */
|
* */
|
||||||
fun loadAllOnlinePlugins(activity: Activity) {
|
fun loadAllOnlinePlugins(context: Context) {
|
||||||
// Load all plugins as fast as possible!
|
// Load all plugins as fast as possible!
|
||||||
(getPluginsOnline()).toList().apmap { pluginData ->
|
(getPluginsOnline()).toList().apmap { pluginData ->
|
||||||
loadPlugin(
|
loadPlugin(
|
||||||
activity,
|
context,
|
||||||
File(pluginData.filePath),
|
File(pluginData.filePath),
|
||||||
pluginData
|
pluginData
|
||||||
)
|
)
|
||||||
|
|
@ -398,9 +429,8 @@ object PluginManager {
|
||||||
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
* @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins
|
||||||
* and reload all pages even if they are previously valid
|
* and reload all pages even if they are previously valid
|
||||||
**/
|
**/
|
||||||
fun loadAllLocalPlugins(activity: Activity, forceReload: Boolean) {
|
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||||
val dir = File(LOCAL_PLUGINS_PATH)
|
val dir = File(LOCAL_PLUGINS_PATH)
|
||||||
removeKey(PLUGINS_KEY_LOCAL)
|
|
||||||
|
|
||||||
if (!dir.exists()) {
|
if (!dir.exists()) {
|
||||||
val res = dir.mkdirs()
|
val res = dir.mkdirs()
|
||||||
|
|
@ -416,7 +446,7 @@ object PluginManager {
|
||||||
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
|
Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins")
|
||||||
|
|
||||||
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
|
sortedPlugins?.sortedBy { it.name }?.apmap { file ->
|
||||||
maybeLoadPlugin(activity, file)
|
maybeLoadPlugin(context, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedLocalPlugins = true
|
loadedLocalPlugins = true
|
||||||
|
|
@ -441,14 +471,22 @@ object PluginManager {
|
||||||
/**
|
/**
|
||||||
* @return True if successful, false if not
|
* @return True if successful, false if not
|
||||||
* */
|
* */
|
||||||
private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean {
|
private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
|
||||||
val fileName = file.nameWithoutExtension
|
val fileName = file.nameWithoutExtension
|
||||||
val filePath = file.absolutePath
|
val filePath = file.absolutePath
|
||||||
currentlyLoading = fileName
|
currentlyLoading = fileName
|
||||||
Log.i(TAG, "Loading plugin: $data")
|
Log.i(TAG, "Loading plugin: $data")
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val loader = PathClassLoader(filePath, activity.classLoader)
|
// in case of android 14 then
|
||||||
|
try {
|
||||||
|
File(filePath).setReadOnly()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to set dex as readonly")
|
||||||
|
logError(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
val loader = PathClassLoader(filePath, context.classLoader)
|
||||||
var manifest: Plugin.Manifest
|
var manifest: Plugin.Manifest
|
||||||
loader.getResourceAsStream("manifest.json").use { stream ->
|
loader.getResourceAsStream("manifest.json").use { stream ->
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
|
|
@ -469,10 +507,12 @@ object PluginManager {
|
||||||
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
|
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
|
||||||
Log.d(TAG, "No manifest version for ${data.internalName}")
|
Log.d(TAG, "No manifest version for ${data.internalName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
val pluginClass: Class<*> =
|
val pluginClass: Class<*> =
|
||||||
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
|
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
|
||||||
val pluginInstance: Plugin =
|
val pluginInstance: Plugin =
|
||||||
pluginClass.newInstance() as Plugin
|
pluginClass.getDeclaredConstructor().newInstance() as Plugin
|
||||||
|
|
||||||
// Sets with the proper version
|
// Sets with the proper version
|
||||||
setPluginData(data.copy(version = version))
|
setPluginData(data.copy(version = version))
|
||||||
|
|
@ -482,32 +522,34 @@ object PluginManager {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
pluginInstance.__filename = fileName
|
pluginInstance.filename = file.absolutePath
|
||||||
if (manifest.requiresResources) {
|
if (manifest.requiresResources) {
|
||||||
Log.d(TAG, "Loading resources for ${data.internalName}")
|
Log.d(TAG, "Loading resources for ${data.internalName}")
|
||||||
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
|
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
|
||||||
val assets = AssetManager::class.java.newInstance()
|
val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
|
||||||
val addAssetPath =
|
val addAssetPath =
|
||||||
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
|
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
|
||||||
addAssetPath.invoke(assets, file.absolutePath)
|
addAssetPath.invoke(assets, file.absolutePath)
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
pluginInstance.resources = Resources(
|
pluginInstance.resources = Resources(
|
||||||
assets,
|
assets,
|
||||||
activity.resources.displayMetrics,
|
context.resources.displayMetrics,
|
||||||
activity.resources.configuration
|
context.resources.configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
plugins[filePath] = pluginInstance
|
plugins[filePath] = pluginInstance
|
||||||
classLoaders[loader] = pluginInstance
|
classLoaders[loader] = pluginInstance
|
||||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||||
pluginInstance.load(activity)
|
pluginInstance.load(context)
|
||||||
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
|
Log.i(TAG, "Loaded plugin ${data.internalName} successfully")
|
||||||
currentlyLoading = null
|
currentlyLoading = null
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
|
Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}")
|
||||||
showToast(
|
showToast(
|
||||||
activity,
|
context.getActivity(),
|
||||||
activity.getString(R.string.plugin_load_fail).format(fileName),
|
context.getString(R.string.plugin_load_fail).format(fileName),
|
||||||
Toast.LENGTH_LONG
|
Toast.LENGTH_LONG
|
||||||
)
|
)
|
||||||
currentlyLoading = null
|
currentlyLoading = null
|
||||||
|
|
@ -530,11 +572,15 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove all registered apis
|
// remove all registered apis
|
||||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
|
synchronized(APIHolder.apis) {
|
||||||
removePluginMapping(it)
|
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||||
|
removePluginMapping(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
|
synchronized(APIHolder.allProviders) {
|
||||||
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
|
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
||||||
|
}
|
||||||
|
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
||||||
|
|
||||||
classLoaders.values.removeIf { v -> v == plugin }
|
classLoaders.values.removeIf { v -> v == plugin }
|
||||||
|
|
||||||
|
|
@ -681,9 +727,14 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = builder.build()
|
val notification = builder.build()
|
||||||
with(NotificationManagerCompat.from(context)) {
|
// notificationId is a unique int for each notification that you must define
|
||||||
// notificationId is a unique int for each notification that you must define
|
if (ActivityCompat.checkSelfPermission(
|
||||||
notify((System.currentTimeMillis() / 1000).toInt(), notification)
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify((System.currentTimeMillis() / 1000).toInt(), notification)
|
||||||
}
|
}
|
||||||
return notification
|
return notification
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package com.lagradost.cloudstream3.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.amap
|
import com.lagradost.cloudstream3.amap
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
|
@ -71,6 +73,15 @@ object RepositoryManager {
|
||||||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||||
}
|
}
|
||||||
|
private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||||
|
|
||||||
|
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
|
||||||
|
fun convertRawGitUrl(url: String): String {
|
||||||
|
if (getKey<Boolean>(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url
|
||||||
|
val match = GH_REGEX.find(url) ?: return url
|
||||||
|
val (user, repo, rest) = match.destructured
|
||||||
|
return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest"
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun parseRepoUrl(url: String): String? {
|
suspend fun parseRepoUrl(url: String): String? {
|
||||||
val fixedUrl = url.trim()
|
val fixedUrl = url.trim()
|
||||||
|
|
@ -84,10 +95,11 @@ object RepositoryManager {
|
||||||
}
|
}
|
||||||
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
|
||||||
suspendSafeApiCall {
|
suspendSafeApiCall {
|
||||||
app.get("https://l.cloudstream.cf/${fixedUrl}").let {
|
app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
|
||||||
return@let if (it.isSuccessful && !it.url.startsWith("https://cutt.ly/branded-domains")) it.url
|
it2.headers["Location"]?.let { url ->
|
||||||
else app.get("https://cutt.ly/${fixedUrl}").let let2@{ it2 ->
|
if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null
|
||||||
return@let2 if (it2.isSuccessful) it2.url else null
|
if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null
|
||||||
|
return@suspendSafeApiCall url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,14 +109,14 @@ object RepositoryManager {
|
||||||
suspend fun parseRepository(url: String): Repository? {
|
suspend fun parseRepository(url: String): Repository? {
|
||||||
return suspendSafeApiCall {
|
return suspendSafeApiCall {
|
||||||
// Take manifestVersion and such into account later
|
// Take manifestVersion and such into account later
|
||||||
app.get(url).parsedSafe()
|
app.get(convertRawGitUrl(url)).parsedSafe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
|
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
|
||||||
// Take manifestVersion and such into account later
|
// Take manifestVersion and such into account later
|
||||||
return try {
|
return try {
|
||||||
val response = app.get(pluginUrls)
|
val response = app.get(convertRawGitUrl(pluginUrls))
|
||||||
// Normal parsed function not working?
|
// Normal parsed function not working?
|
||||||
// return response.parsedSafe()
|
// return response.parsedSafe()
|
||||||
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
||||||
|
|
@ -139,7 +151,7 @@ object RepositoryManager {
|
||||||
}
|
}
|
||||||
file.createNewFile()
|
file.createNewFile()
|
||||||
|
|
||||||
val body = app.get(pluginUrl).okhttpResponse.body
|
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||||
write(body.byteStream(), file.outputStream())
|
write(body.byteStream(), file.outputStream())
|
||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
object VotingApi { // please do not cheat the votes lol
|
object VotingApi { // please do not cheat the votes lol
|
||||||
private const val LOGKEY = "VotingApi"
|
private const val LOGKEY = "VotingApi"
|
||||||
|
|
||||||
enum class VoteType(val value: Int) {
|
private const val API_DOMAIN = "https://counterapi.com/api"
|
||||||
UPVOTE(1),
|
|
||||||
DOWNVOTE(-1),
|
|
||||||
NONE(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val apiDomain = "https://api.countapi.xyz"
|
|
||||||
|
|
||||||
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
||||||
MessageDigest
|
MessageDigest
|
||||||
|
|
@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol
|
||||||
return getVotes(url)
|
return getVotes(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun SitePlugin.vote(requestType: VoteType): Int {
|
fun SitePlugin.hasVoted(): Boolean {
|
||||||
return vote(url, requestType)
|
return hasVoted(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SitePlugin.getVoteType(): VoteType {
|
suspend fun SitePlugin.vote(): Int {
|
||||||
return getVoteType(url)
|
return vote(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SitePlugin.canVote(): Boolean {
|
fun SitePlugin.canVote(): Boolean {
|
||||||
|
|
@ -50,36 +42,38 @@ object VotingApi { // please do not cheat the votes lol
|
||||||
// Plugin url to Int
|
// Plugin url to Int
|
||||||
private val votesCache = mutableMapOf<String, Int>()
|
private val votesCache = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
suspend fun getVotes(pluginUrl: String): Int {
|
private fun getRepository(pluginUrl: String) = pluginUrl
|
||||||
val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}"
|
.split("/")
|
||||||
|
.drop(2)
|
||||||
|
.take(3)
|
||||||
|
.joinToString("-")
|
||||||
|
|
||||||
|
private suspend fun readVote(pluginUrl: String): Int {
|
||||||
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
||||||
Log.d(LOGKEY, "Requesting: $url")
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
return votesCache[pluginUrl] ?: app.get(url).parsedSafe<Result>()?.value?.also {
|
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
||||||
votesCache[pluginUrl] = it
|
}
|
||||||
} ?: (0.also {
|
|
||||||
ioSafe {
|
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||||
createBucket(pluginUrl)
|
val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
|
||||||
|
Log.d(LOGKEY, "Requesting: $url")
|
||||||
|
return app.get(url).parsedSafe<Result>()?.value != null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getVotes(pluginUrl: String): Int =
|
||||||
|
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
|
||||||
|
votesCache[pluginUrl] = it
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getVoteType(pluginUrl: String): VoteType {
|
fun hasVoted(pluginUrl: String) =
|
||||||
return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
|
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createBucket(pluginUrl: String) {
|
|
||||||
val url =
|
|
||||||
"${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0"
|
|
||||||
Log.d(LOGKEY, "Requesting: $url")
|
|
||||||
app.get(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canVote(pluginUrl: String): Boolean {
|
fun canVote(pluginUrl: String): Boolean {
|
||||||
if (!PluginManager.urlPlugins.contains(pluginUrl)) return false
|
return PluginManager.urlPlugins.contains(pluginUrl)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val voteLock = Mutex()
|
private val voteLock = Mutex()
|
||||||
suspend fun vote(pluginUrl: String, requestType: VoteType): Int {
|
suspend fun vote(pluginUrl: String): Int {
|
||||||
// Prevent multiple requests at the same time.
|
// Prevent multiple requests at the same time.
|
||||||
voteLock.withLock {
|
voteLock.withLock {
|
||||||
if (!canVote(pluginUrl)) {
|
if (!canVote(pluginUrl)) {
|
||||||
|
|
@ -90,33 +84,21 @@ object VotingApi { // please do not cheat the votes lol
|
||||||
return getVotes(pluginUrl)
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
val savedType: VoteType =
|
if (hasVoted(pluginUrl)) {
|
||||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
|
main {
|
||||||
|
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
||||||
val newType = if (requestType == savedType) VoteType.NONE else requestType
|
.show()
|
||||||
val changeValue = if (requestType == savedType) {
|
}
|
||||||
-requestType.value
|
return getVotes(pluginUrl)
|
||||||
} else if (savedType == VoteType.NONE) {
|
|
||||||
requestType.value
|
|
||||||
} else if (savedType != requestType) {
|
|
||||||
-savedType.value + requestType.value
|
|
||||||
} else 0
|
|
||||||
|
|
||||||
// Pre-emptively set vote key
|
|
||||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", newType)
|
|
||||||
|
|
||||||
val url =
|
|
||||||
"${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}"
|
|
||||||
Log.d(LOGKEY, "Requesting: $url")
|
|
||||||
val res = app.get(url).parsedSafe<Result>()?.value
|
|
||||||
|
|
||||||
if (res == null) {
|
|
||||||
// "Refund" key if the response is invalid
|
|
||||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType)
|
|
||||||
} else {
|
|
||||||
votesCache[pluginUrl] = res
|
|
||||||
}
|
}
|
||||||
return res ?: 0
|
|
||||||
|
|
||||||
|
if (writeVote(pluginUrl)) {
|
||||||
|
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||||
|
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVotes(pluginUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
const val BACKUP_CHANNEL_ID = "cloudstream3.backups"
|
||||||
|
const val BACKUP_WORK_NAME = "work_backup"
|
||||||
|
const val BACKUP_CHANNEL_NAME = "Backups"
|
||||||
|
const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups"
|
||||||
|
const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique
|
||||||
|
|
||||||
|
class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
|
||||||
|
CoroutineWorker(context, workerParams) {
|
||||||
|
companion object {
|
||||||
|
fun enqueuePeriodicWork(context: Context?, intervalHours: Long) {
|
||||||
|
if (context == null) return
|
||||||
|
|
||||||
|
if (intervalHours == 0L) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiresStorageNotLow(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val periodicSyncDataWork =
|
||||||
|
PeriodicWorkRequest.Builder(
|
||||||
|
BackupWorkManager::class.java,
|
||||||
|
intervalHours,
|
||||||
|
TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.addTag(BACKUP_WORK_NAME)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
BACKUP_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicSyncDataWork
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uncomment below for testing
|
||||||
|
|
||||||
|
// val oneTimeBackupWork =
|
||||||
|
// OneTimeWorkRequest.Builder(BackupWorkManager::class.java)
|
||||||
|
// .addTag(BACKUP_WORK_NAME)
|
||||||
|
// .setConstraints(constraints)
|
||||||
|
// .build()
|
||||||
|
//
|
||||||
|
// WorkManager.getInstance(context).enqueue(oneTimeBackupWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val backupNotificationBuilder =
|
||||||
|
NotificationCompat.Builder(context, BACKUP_CHANNEL_ID)
|
||||||
|
.setColorized(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentTitle(context.getString(R.string.pref_category_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||||
|
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
context.createNotificationChannel(
|
||||||
|
BACKUP_CHANNEL_ID,
|
||||||
|
BACKUP_CHANNEL_NAME,
|
||||||
|
BACKUP_CHANNEL_DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
|
setForeground(
|
||||||
|
ForegroundInfo(
|
||||||
|
BACKUP_NOTIFICATION_ID,
|
||||||
|
backupNotificationBuilder.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupUtils.backup(context)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.*
|
||||||
|
import com.lagradost.cloudstream3.*
|
||||||
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions"
|
||||||
|
const val SUBSCRIPTION_WORK_NAME = "work_subscription"
|
||||||
|
const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions"
|
||||||
|
const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows"
|
||||||
|
const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique
|
||||||
|
|
||||||
|
class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) :
|
||||||
|
CoroutineWorker(context, workerParams) {
|
||||||
|
companion object {
|
||||||
|
fun enqueuePeriodicWork(context: Context?) {
|
||||||
|
if (context == null) return
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val periodicSyncDataWork =
|
||||||
|
PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS)
|
||||||
|
.addTag(SUBSCRIPTION_WORK_NAME)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
SUBSCRIPTION_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
periodicSyncDataWork
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uncomment below for testing
|
||||||
|
|
||||||
|
// val oneTimeSyncDataWork =
|
||||||
|
// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java)
|
||||||
|
// .addTag(SUBSCRIPTION_WORK_NAME)
|
||||||
|
// .setConstraints(constraints)
|
||||||
|
// .build()
|
||||||
|
//
|
||||||
|
// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val progressNotificationBuilder =
|
||||||
|
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setColorized(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||||
|
.setContentTitle(context.getString(R.string.subscription_in_progress_notification))
|
||||||
|
.setSmallIcon(R.drawable.quantum_ic_refresh_white_24)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
|
||||||
|
private val updateNotificationBuilder =
|
||||||
|
NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID)
|
||||||
|
.setColorized(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||||
|
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
|
||||||
|
|
||||||
|
private val notificationManager: NotificationManager =
|
||||||
|
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
|
private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
notificationManager.notify(
|
||||||
|
SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder
|
||||||
|
.setProgress(max, progress, indeterminate)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedImmutableFlag")
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
try {
|
||||||
|
// println("Update subscriptions!")
|
||||||
|
context.createNotificationChannel(
|
||||||
|
SUBSCRIPTION_CHANNEL_ID,
|
||||||
|
SUBSCRIPTION_CHANNEL_NAME,
|
||||||
|
SUBSCRIPTION_CHANNEL_DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
|
setForeground(
|
||||||
|
ForegroundInfo(
|
||||||
|
SUBSCRIPTION_NOTIFICATION_ID,
|
||||||
|
progressNotificationBuilder.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val subscriptions = getAllSubscriptions()
|
||||||
|
|
||||||
|
if (subscriptions.isEmpty()) {
|
||||||
|
WorkManager.getInstance(context).cancelWorkById(this.id)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val max = subscriptions.size
|
||||||
|
var progress = 0
|
||||||
|
|
||||||
|
updateProgress(max, progress, true)
|
||||||
|
|
||||||
|
// We need all plugins loaded.
|
||||||
|
PluginManager.loadAllOnlinePlugins(context)
|
||||||
|
PluginManager.loadAllLocalPlugins(context, false)
|
||||||
|
|
||||||
|
subscriptions.apmap { savedData ->
|
||||||
|
try {
|
||||||
|
val id = savedData.id ?: return@apmap null
|
||||||
|
val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
|
||||||
|
|
||||||
|
// Reasonable timeout to prevent having this worker run forever.
|
||||||
|
val response = withTimeoutOrNull(60_000) {
|
||||||
|
api.load(savedData.url) as? EpisodeResponse
|
||||||
|
} ?: return@apmap null
|
||||||
|
|
||||||
|
val dubPreference =
|
||||||
|
getDub(id) ?: if (
|
||||||
|
context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
|
||||||
|
) {
|
||||||
|
DubStatus.Dubbed
|
||||||
|
} else {
|
||||||
|
DubStatus.Subbed
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestEpisodes = response.getLatestEpisodes()
|
||||||
|
val latestPreferredEpisode = latestEpisodes[dubPreference]
|
||||||
|
|
||||||
|
val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
|
||||||
|
val latestSeenEpisode =
|
||||||
|
savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
|
||||||
|
val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
|
||||||
|
shouldUpdate to latestPreferredEpisode
|
||||||
|
} else {
|
||||||
|
val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
|
||||||
|
val latestSeenEpisode =
|
||||||
|
savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
|
||||||
|
val shouldUpdate = latestEpisode > latestSeenEpisode
|
||||||
|
shouldUpdate to latestEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
DataStoreHelper.updateSubscribedData(
|
||||||
|
id,
|
||||||
|
savedData,
|
||||||
|
response
|
||||||
|
)
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
val updateHeader = savedData.name
|
||||||
|
val updateDescription = txt(
|
||||||
|
R.string.subscription_episode_released,
|
||||||
|
latestEpisode,
|
||||||
|
savedData.name
|
||||||
|
).asString(context)
|
||||||
|
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = savedData.url.toUri()
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PendingIntent.getActivity(context, 0, intent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val poster = ioWork {
|
||||||
|
savedData.posterUrl?.let { url ->
|
||||||
|
context.getImageBitmapFromUrl(
|
||||||
|
url,
|
||||||
|
savedData.posterHeaders
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val updateNotification =
|
||||||
|
updateNotificationBuilder.setContentTitle(updateHeader)
|
||||||
|
.setContentText(updateDescription)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setLargeIcon(poster)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
notificationManager.notify(id, updateNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
// You can probably get some issues here since this is async but it does not matter much.
|
||||||
|
updateProgress(max, ++progress, false)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
// ye, while this is not correct, but because gods know why android just crashes
|
||||||
|
// and this causes major battery usage as it retries it inf times. This is better, just
|
||||||
|
// in case android decides to be android and fuck us
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
package com.lagradost.cloudstream3.services
|
package com.lagradost.cloudstream3.services
|
||||||
|
import android.app.Service
|
||||||
import android.app.IntentService
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class VideoDownloadService : IntentService("VideoDownloadService") {
|
class VideoDownloadService : Service() {
|
||||||
override fun onHandleIntent(intent: Intent?) {
|
|
||||||
|
private val downloadScope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
val id = intent.getIntExtra("id", -1)
|
val id = intent.getIntExtra("id", -1)
|
||||||
val type = intent.getStringExtra("type")
|
val type = intent.getStringExtra("type")
|
||||||
|
|
@ -14,10 +25,36 @@ class VideoDownloadService : IntentService("VideoDownloadService") {
|
||||||
"resume" -> VideoDownloadManager.DownloadActionType.Resume
|
"resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||||
"pause" -> VideoDownloadManager.DownloadActionType.Pause
|
"pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||||
"stop" -> VideoDownloadManager.DownloadActionType.Stop
|
"stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||||
else -> return
|
else -> return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadScope.launch {
|
||||||
|
VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||||
}
|
}
|
||||||
VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
downloadScope.coroutineContext.cancel()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// override fun onHandleIntent(intent: Intent?) {
|
||||||
|
// if (intent != null) {
|
||||||
|
// val id = intent.getIntExtra("id", -1)
|
||||||
|
// val type = intent.getStringExtra("type")
|
||||||
|
// if (id != -1 && type != null) {
|
||||||
|
// val state = when (type) {
|
||||||
|
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
|
||||||
|
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
|
||||||
|
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
|
||||||
|
// else -> return
|
||||||
|
// }
|
||||||
|
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
package com.lagradost.cloudstream3.subtitles
|
package com.lagradost.cloudstream3.subtitles
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
|
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
|
||||||
|
import okio.BufferedSource
|
||||||
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
interface AbstractSubProvider {
|
interface AbstractSubProvider {
|
||||||
|
val idPrefix: String
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
|
suspend fun search(query: SubtitleSearch): List<SubtitleEntity>? {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
|
|
@ -15,6 +27,98 @@ interface AbstractSubProvider {
|
||||||
suspend fun load(data: SubtitleEntity): String? {
|
suspend fun load(data: SubtitleEntity): String? {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
|
||||||
|
this.addUrl(load(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
suspend fun getResource(data: SubtitleEntity): SubtitleResource {
|
||||||
|
return SubtitleResource().apply {
|
||||||
|
this.getResources(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A builder for subtitle files.
|
||||||
|
* @see addUrl
|
||||||
|
* @see addFile
|
||||||
|
*/
|
||||||
|
class SubtitleResource {
|
||||||
|
fun downloadFile(source: BufferedSource): File {
|
||||||
|
val file = File.createTempFile("temp-subtitle", ".tmp").apply {
|
||||||
|
deleteFileOnExit(this)
|
||||||
|
}
|
||||||
|
val sink = file.sink().buffer()
|
||||||
|
sink.writeAll(source)
|
||||||
|
sink.close()
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unzip(file: File): List<Pair<String, File>> {
|
||||||
|
val entries = mutableListOf<Pair<String, File>>()
|
||||||
|
|
||||||
|
ZipInputStream(file.inputStream()).use { zipInputStream ->
|
||||||
|
var zipEntry = zipInputStream.nextEntry
|
||||||
|
|
||||||
|
while (zipEntry != null) {
|
||||||
|
val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply {
|
||||||
|
deleteFileOnExit(this)
|
||||||
|
}
|
||||||
|
entries.add(zipEntry.name to tempFile)
|
||||||
|
|
||||||
|
tempFile.sink().buffer().use { buffer ->
|
||||||
|
buffer.writeAll(zipInputStream.source())
|
||||||
|
}
|
||||||
|
|
||||||
|
zipEntry = zipInputStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SingleSubtitleResource(
|
||||||
|
val name: String?,
|
||||||
|
val url: String,
|
||||||
|
val origin: SubtitleOrigin
|
||||||
|
)
|
||||||
|
|
||||||
|
private var resources: MutableList<SingleSubtitleResource> = mutableListOf()
|
||||||
|
|
||||||
|
fun getSubtitles(): List<SingleSubtitleResource> {
|
||||||
|
return resources.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addUrl(url: String?, name: String? = null) {
|
||||||
|
if (url == null) return
|
||||||
|
this.resources.add(
|
||||||
|
SingleSubtitleResource(name, url, SubtitleOrigin.URL)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addFile(file: File, name: String? = null) {
|
||||||
|
this.resources.add(
|
||||||
|
SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE)
|
||||||
|
)
|
||||||
|
deleteFileOnExit(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addZipUrl(
|
||||||
|
url: String,
|
||||||
|
nameGenerator: (String, File) -> String? = { _, _ -> null }
|
||||||
|
) {
|
||||||
|
val source = app.get(url).okhttpResponse.body.source()
|
||||||
|
val zip = downloadFile(source)
|
||||||
|
val realFiles = unzip(zip)
|
||||||
|
zip.deleteRecursively()
|
||||||
|
realFiles.forEach { (name, subtitleFile) ->
|
||||||
|
addFile(subtitleFile, nameGenerator(name, subtitleFile))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AbstractSubApi : AbstractSubProvider, AuthAPI
|
interface AbstractSubApi : AbstractSubProvider, AuthAPI
|
||||||
|
|
@ -19,8 +19,11 @@ class AbstractSubtitleEntities {
|
||||||
|
|
||||||
data class SubtitleSearch(
|
data class SubtitleSearch(
|
||||||
var query: String = "",
|
var query: String = "",
|
||||||
var imdb: Long? = null,
|
|
||||||
var lang: String? = null,
|
var lang: String? = null,
|
||||||
|
var imdbId: String? = null,
|
||||||
|
var tmdbId: Int? = null,
|
||||||
|
var malId: Int? = null,
|
||||||
|
var aniListId: Int? = null,
|
||||||
var epNumber: Int? = null,
|
var epNumber: Int? = null,
|
||||||
var seasonNumber: Int? = null,
|
var seasonNumber: Int? = null,
|
||||||
var year: Int? = null
|
var year: Int? = null
|
||||||
|
|
|
||||||
|
|
@ -3,61 +3,75 @@ package com.lagradost.cloudstream3.syncproviders
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.*
|
import com.lagradost.cloudstream3.syncproviders.providers.*
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
companion object {
|
companion object {
|
||||||
val malApi = MALApi(0)
|
val malApi = MALApi(0).also { api ->
|
||||||
val aniListApi = AniListApi(0)
|
LoadResponse.Companion.malIdPrefix = api.idPrefix
|
||||||
|
}
|
||||||
|
val aniListApi = AniListApi(0).also { api ->
|
||||||
|
LoadResponse.Companion.aniListIdPrefix = api.idPrefix
|
||||||
|
}
|
||||||
|
val simklApi = SimklApi(0).also { api ->
|
||||||
|
LoadResponse.Companion.simklIdPrefix = api.idPrefix
|
||||||
|
}
|
||||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||||
val indexSubtitlesApi = IndexSubtitleApi()
|
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
|
val subDlApi = SubDlApi(0)
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
val subSourceApi = SubSourceApi()
|
||||||
|
|
||||||
// used to login via app intent
|
// used to login via app intent
|
||||||
val OAuth2Apis
|
val OAuth2Apis
|
||||||
get() = listOf<OAuth2API>(
|
get() = listOf<OAuth2API>(
|
||||||
malApi, aniListApi
|
malApi, aniListApi, simklApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// this needs init with context and can be accessed in settings
|
// this needs init with context and can be accessed in settings
|
||||||
val accountManagers
|
val accountManagers
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
malApi, aniListApi, openSubtitlesApi, //nginxApi
|
malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// used for active syncing
|
// used for active syncing
|
||||||
val SyncApis
|
val SyncApis
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
|
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||||
)
|
)
|
||||||
|
|
||||||
val inAppAuths
|
val inAppAuths
|
||||||
get() = listOf(openSubtitlesApi)//, nginxApi)
|
get() = listOf<InAppAuthAPIManager>(
|
||||||
|
openSubtitlesApi,
|
||||||
|
subDlApi
|
||||||
|
)//, nginxApi)
|
||||||
|
|
||||||
val subtitleProviders
|
val subtitleProviders
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
openSubtitlesApi,
|
openSubtitlesApi,
|
||||||
indexSubtitlesApi, // they got anti scraping measures in place :(
|
addic7ed,
|
||||||
addic7ed
|
subDlApi,
|
||||||
|
subSourceApi
|
||||||
)
|
)
|
||||||
|
|
||||||
const val appString = "cloudstreamapp"
|
const val APP_STRING = "cloudstreamapp"
|
||||||
const val appStringRepo = "cloudstreamrepo"
|
const val APP_STRING_REPO = "cloudstreamrepo"
|
||||||
|
const val APP_STRING_PLAYER = "cloudstreamplayer"
|
||||||
|
|
||||||
// Instantly start the search given a query
|
// Instantly start the search given a query
|
||||||
const val appStringSearch = "cloudstreamsearch"
|
const val APP_STRING_SEARCH = "cloudstreamsearch"
|
||||||
|
|
||||||
// Instantly resume watching a show
|
// Instantly resume watching a show
|
||||||
const val appStringResumeWatching = "cloudstreamcontinuewatching"
|
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
|
||||||
|
|
||||||
val unixTime: Long
|
val unixTime: Long
|
||||||
get() = System.currentTimeMillis() / 1000L
|
get() = System.currentTimeMillis() / 1000L
|
||||||
val unixTimeMs: Long
|
val unixTimeMs: Long
|
||||||
get() = System.currentTimeMillis()
|
get() = System.currentTimeMillis()
|
||||||
|
|
||||||
const val maxStale = 60 * 10
|
const val MAX_STALE = 60 * 10
|
||||||
|
|
||||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||||
var secondsLong = seconds.toLong()
|
var secondsLong = seconds.toLong()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity
|
||||||
interface OAuth2API : AuthAPI {
|
interface OAuth2API : AuthAPI {
|
||||||
val key: String
|
val key: String
|
||||||
val redirectUrl: String
|
val redirectUrl: String
|
||||||
|
val supportDeviceAuth: Boolean
|
||||||
|
|
||||||
suspend fun handleRedirect(url: String) : Boolean
|
suspend fun handleRedirect(url: String) : Boolean
|
||||||
fun authenticate(activity: FragmentActivity?)
|
fun authenticate(activity: FragmentActivity?)
|
||||||
|
suspend fun getDevicePin() : PinAuthData? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PinAuthData(
|
||||||
|
val deviceCode: String,
|
||||||
|
val userCode: String,
|
||||||
|
val verificationUrl: String,
|
||||||
|
val expiresIn: Int,
|
||||||
|
val interval: Int,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.*
|
||||||
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.ui.result.UiText
|
import com.lagradost.cloudstream3.ui.result.UiText
|
||||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||||
|
import java.util.Date
|
||||||
enum class SyncIdName {
|
|
||||||
Anilist,
|
|
||||||
MyAnimeList,
|
|
||||||
Trakt,
|
|
||||||
Imdb,
|
|
||||||
LocalList
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SyncAPI : OAuth2API {
|
interface SyncAPI : OAuth2API {
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,9 +29,9 @@ interface SyncAPI : OAuth2API {
|
||||||
4 -> PlanToWatch
|
4 -> PlanToWatch
|
||||||
5 -> ReWatching
|
5 -> ReWatching
|
||||||
*/
|
*/
|
||||||
suspend fun score(id: String, status: SyncStatus): Boolean
|
suspend fun score(id: String, status: AbstractSyncStatus): Boolean
|
||||||
|
|
||||||
suspend fun getStatus(id: String): SyncStatus?
|
suspend fun getStatus(id: String): AbstractSyncStatus?
|
||||||
|
|
||||||
suspend fun getResult(id: String): SyncResult?
|
suspend fun getResult(id: String): SyncResult?
|
||||||
|
|
||||||
|
|
@ -59,14 +53,25 @@ interface SyncAPI : OAuth2API {
|
||||||
override var id: Int? = null,
|
override var id: Int? = null,
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
|
|
||||||
data class SyncStatus(
|
abstract class AbstractSyncStatus {
|
||||||
val status: Int,
|
abstract var status: SyncWatchType
|
||||||
|
|
||||||
/** 1-10 */
|
/** 1-10 */
|
||||||
val score: Int?,
|
abstract var score: Int?
|
||||||
val watchedEpisodes: Int?,
|
abstract var watchedEpisodes: Int?
|
||||||
var isFavorite: Boolean? = null,
|
abstract var isFavorite: Boolean?
|
||||||
var maxEpisodes: Int? = null,
|
abstract var maxEpisodes: Int?
|
||||||
)
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class SyncStatus(
|
||||||
|
override var status: SyncWatchType,
|
||||||
|
/** 1-10 */
|
||||||
|
override var score: Int?,
|
||||||
|
override var watchedEpisodes: Int?,
|
||||||
|
override var isFavorite: Boolean? = null,
|
||||||
|
override var maxEpisodes: Int? = null,
|
||||||
|
) : AbstractSyncStatus()
|
||||||
|
|
||||||
data class SyncResult(
|
data class SyncResult(
|
||||||
/**Used to verify*/
|
/**Used to verify*/
|
||||||
|
|
@ -120,6 +125,8 @@ interface SyncAPI : OAuth2API {
|
||||||
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
|
||||||
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
|
||||||
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
|
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
|
||||||
|
ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
|
||||||
|
ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
|
||||||
else -> items
|
else -> items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +161,10 @@ interface SyncAPI : OAuth2API {
|
||||||
override var posterUrl: String?,
|
override var posterUrl: String?,
|
||||||
override var posterHeaders: Map<String, String>?,
|
override var posterHeaders: Map<String, String>?,
|
||||||
override var quality: SearchQuality?,
|
override var quality: SearchQuality?,
|
||||||
|
val releaseDate: Date?,
|
||||||
override var id: Int? = null,
|
override var id: Int? = null,
|
||||||
|
val plot : String? = null,
|
||||||
|
val rating: Int? = null,
|
||||||
|
val tags: List<String>? = null
|
||||||
) : SearchResponse
|
) : SearchResponse
|
||||||
}
|
}
|
||||||
|
|
@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
|
||||||
repo.requireLibraryRefresh = value
|
repo.requireLibraryRefresh = value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource<Boolean> {
|
suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
|
||||||
return safeApiCall { repo.score(id, status) }
|
return safeApiCall { repo.score(id, status) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getStatus(id: String): Resource<SyncAPI.SyncStatus> {
|
suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
|
||||||
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
|
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi {
|
||||||
override fun logOut() {}
|
override fun logOut() {}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val host = "https://www.addic7ed.com"
|
const val HOST = "https://www.addic7ed.com"
|
||||||
const val TAG = "ADDIC7ED"
|
const val TAG = "ADDIC7ED"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fixUrl(url: String): String {
|
private fun fixUrl(url: String): String {
|
||||||
return if (url.startsWith("/")) host + url
|
return if (url.startsWith("/")) HOST + url
|
||||||
else if (!url.startsWith("http")) "$host/$url"
|
else if (!url.startsWith("http")) "$HOST/$url"
|
||||||
else url
|
else url
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
val title = queryText.substringBefore("(").trim()
|
val title = queryText.substringBefore("(").trim()
|
||||||
val url = "$host/search.php?search=${title}&Submit=Search"
|
val url = "$HOST/search.php?search=${title}&Submit=Search"
|
||||||
val hostDocument = app.get(url).document
|
val hostDocument = app.get(url).document
|
||||||
var searchResult = ""
|
var searchResult = ""
|
||||||
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
|
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
|
||||||
|
|
@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi {
|
||||||
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
|
||||||
?.substringBefore(",")
|
?.substringBefore(",")
|
||||||
val doc = app.get(
|
val doc = app.get(
|
||||||
"$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
|
"$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
|
||||||
referer = "$host/"
|
referer = "$HOST/"
|
||||||
).document
|
).document
|
||||||
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
|
||||||
if (node.selectFirst("td")?.text()
|
if (node.selectFirst("td")?.text()
|
||||||
|
|
@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi {
|
||||||
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
|
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
|
||||||
val isHearingImpaired =
|
val isHearingImpaired =
|
||||||
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
|
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
|
||||||
cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired)
|
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,19 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.*
|
import java.util.Locale
|
||||||
|
|
||||||
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override var name = "AniList"
|
override var name = "AniList"
|
||||||
|
|
@ -31,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override val redirectUrl = "anilistlogin"
|
override val redirectUrl = "anilistlogin"
|
||||||
override val idPrefix = "anilist"
|
override val idPrefix = "anilist"
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
|
override val supportDeviceAuth = false
|
||||||
override var mainUrl = "https://anilist.co"
|
override var mainUrl = "https://anilist.co"
|
||||||
override val icon = R.drawable.ic_anilist_icon
|
override val icon = R.drawable.ic_anilist_icon
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
|
|
@ -61,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
override suspend fun handleRedirect(url: String): Boolean {
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
val sanitizer =
|
val sanitizer =
|
||||||
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
|
splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
|
||||||
val token = sanitizer["access_token"]!!
|
val token = sanitizer["access_token"]!!
|
||||||
val expiresIn = sanitizer["expires_in"]!!
|
val expiresIn = sanitizer["expires_in"]!!
|
||||||
|
|
||||||
|
|
@ -85,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult>? {
|
||||||
val data = searchShows(name) ?: return null
|
val data = searchShows(name) ?: return null
|
||||||
return data.data?.Page?.media?.map {
|
return data.data?.page?.media?.map {
|
||||||
SyncAPI.SyncSearchResult(
|
SyncAPI.SyncSearchResult(
|
||||||
it.title.romaji ?: return null,
|
it.title.romaji ?: return null,
|
||||||
this.name,
|
this.name,
|
||||||
|
|
@ -99,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
override suspend fun getResult(id: String): SyncAPI.SyncResult {
|
override suspend fun getResult(id: String): SyncAPI.SyncResult {
|
||||||
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
|
||||||
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
|
||||||
val season = getSeason(internalId).data.Media
|
val season = getSeason(internalId).data.media
|
||||||
|
|
||||||
return SyncAPI.SyncResult(
|
return SyncAPI.SyncResult(
|
||||||
season.id.toString(),
|
season.id.toString(),
|
||||||
|
|
@ -158,23 +161,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
val data = getDataAboutId(internalId) ?: return null
|
val data = getDataAboutId(internalId) ?: return null
|
||||||
|
|
||||||
return SyncAPI.SyncStatus(
|
return SyncAPI.SyncStatus(
|
||||||
score = data.score,
|
score = data.score,
|
||||||
watchedEpisodes = data.progress,
|
watchedEpisodes = data.progress,
|
||||||
status = data.type?.value ?: return null,
|
status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
|
||||||
isFavorite = data.isFavourite,
|
isFavorite = data.isFavourite,
|
||||||
maxEpisodes = data.episodes,
|
maxEpisodes = data.episodes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
return postDataAboutId(
|
return postDataAboutId(
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(status.status),
|
fromIntToAnimeStatus(status.status.internalId),
|
||||||
status.score,
|
status.score,
|
||||||
status.watchedEpisodes
|
status.watchedEpisodes
|
||||||
).also {
|
).also {
|
||||||
|
|
@ -299,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
|
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
|
||||||
val shows = searchShows(name.replace(blackListRegex, ""))
|
val shows = searchShows(name.replace(blackListRegex, ""))
|
||||||
|
|
||||||
shows?.data?.Page?.media?.find {
|
shows?.data?.page?.media?.find {
|
||||||
(malId ?: "NONE") == it.idMal.toString()
|
(malId ?: "NONE") == it.idMal.toString()
|
||||||
}?.let { return it }
|
}?.let { return it }
|
||||||
|
|
||||||
val filtered =
|
val filtered =
|
||||||
shows?.data?.Page?.media?.filter {
|
shows?.data?.page?.media?.filter {
|
||||||
(((it.startDate.year ?: year.toString()) == year.toString()
|
(((it.startDate.year ?: year.toString()) == year.toString()
|
||||||
|| year == null))
|
|| year == null))
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
val data = postApi(q, true)
|
val data = postApi(q, true)
|
||||||
val d = parseJson<GetDataRoot>(data ?: return null)
|
val d = parseJson<GetDataRoot>(data ?: return null)
|
||||||
|
|
||||||
val main = d.data?.Media
|
val main = d.data?.media
|
||||||
if (main?.mediaListEntry != null) {
|
if (main?.mediaListEntry != null) {
|
||||||
return AniListTitleHolder(
|
return AniListTitleHolder(
|
||||||
title = main.title,
|
title = main.title,
|
||||||
|
|
@ -534,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
"Authorization" to "Bearer " + (getAuth()
|
"Authorization" to "Bearer " + (getAuth()
|
||||||
?: return@suspendSafeApiCall null),
|
?: return@suspendSafeApiCall null),
|
||||||
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
|
if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
|
||||||
),
|
),
|
||||||
cacheTime = 0,
|
cacheTime = 0,
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
|
|
@ -595,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
//@JsonProperty("source") val source: String,
|
//@JsonProperty("source") val source: String,
|
||||||
@JsonProperty("episodes") val episodes: Int,
|
@JsonProperty("episodes") val episodes: Int,
|
||||||
@JsonProperty("title") val title: Title,
|
@JsonProperty("title") val title: Title,
|
||||||
//@JsonProperty("description") val description: String,
|
@JsonProperty("description") val description: String?,
|
||||||
@JsonProperty("coverImage") val coverImage: CoverImage,
|
@JsonProperty("coverImage") val coverImage: CoverImage,
|
||||||
@JsonProperty("synonyms") val synonyms: List<String>,
|
@JsonProperty("synonyms") val synonyms: List<String>,
|
||||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||||
|
|
@ -629,7 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
?: this.media.coverImage.medium,
|
?: this.media.coverImage.medium,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null
|
this.media.seasonYear.toYear(),
|
||||||
|
null,
|
||||||
|
plot = this.media.description,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Data(
|
data class Data(
|
||||||
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
|
@JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun getAniListListCached(): Array<Lists>? {
|
private fun getAniListListCached(): Array<Lists>? {
|
||||||
|
|
@ -656,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
if (checkToken()) return null
|
if (checkToken()) return null
|
||||||
return if (requireLibraryRefresh) {
|
return if (requireLibraryRefresh) {
|
||||||
val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray()
|
val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
|
||||||
if (list != null) {
|
if (list != null) {
|
||||||
setKey(ANILIST_CACHED_LIST, list)
|
setKey(ANILIST_CACHED_LIST, list)
|
||||||
}
|
}
|
||||||
|
|
@ -675,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
// To fill empty lists when AniList does not return them
|
// To fill empty lists when AniList does not return them
|
||||||
val baseMap =
|
val baseMap =
|
||||||
AniListStatusType.values().filter { it.value >= 0 }.associate {
|
AniListStatusType.entries.filter { it.value >= 0 }.associate {
|
||||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -686,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
ListSorting.AlphabeticalZ,
|
ListSorting.AlphabeticalZ,
|
||||||
ListSorting.UpdatedNew,
|
ListSorting.UpdatedNew,
|
||||||
ListSorting.UpdatedOld,
|
ListSorting.UpdatedOld,
|
||||||
|
ListSorting.ReleaseDateNew,
|
||||||
|
ListSorting.ReleaseDateOld,
|
||||||
ListSorting.RatingHigh,
|
ListSorting.RatingHigh,
|
||||||
ListSorting.RatingLow,
|
ListSorting.RatingLow,
|
||||||
)
|
)
|
||||||
|
|
@ -759,6 +766,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Used to query a saved MediaItem on the list to get the id for removal */
|
||||||
|
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
|
||||||
|
data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
|
||||||
|
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||||
|
|
||||||
private suspend fun postDataAboutId(
|
private suspend fun postDataAboutId(
|
||||||
id: Int,
|
id: Int,
|
||||||
type: AniListStatusType,
|
type: AniListStatusType,
|
||||||
|
|
@ -766,19 +778,43 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
progress: Int?
|
progress: Int?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val q =
|
val q =
|
||||||
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
|
// Delete item if status type is None
|
||||||
aniListStatusString[maxOf(
|
if (type == AniListStatusType.None) {
|
||||||
0,
|
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return false
|
||||||
type.value
|
// Get list ID for deletion
|
||||||
)]
|
val idQuery = """
|
||||||
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
|
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
|
||||||
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) {
|
||||||
id
|
id
|
||||||
status
|
}
|
||||||
progress
|
}
|
||||||
score
|
"""
|
||||||
}
|
val response = postApi(idQuery)
|
||||||
|
val listId =
|
||||||
|
tryParseJson<MediaListItemRoot>(response)?.data?.mediaList?.id ?: return false
|
||||||
|
"""
|
||||||
|
mutation(${'$'}id: Int = $listId) {
|
||||||
|
DeleteMediaListEntry(id: ${'$'}id) {
|
||||||
|
deleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
|
||||||
|
aniListStatusString[maxOf(
|
||||||
|
0,
|
||||||
|
type.value
|
||||||
|
)]
|
||||||
|
}, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) {
|
||||||
|
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
progress
|
||||||
|
score
|
||||||
|
}
|
||||||
}"""
|
}"""
|
||||||
|
}
|
||||||
|
|
||||||
val data = postApi(q)
|
val data = postApi(q)
|
||||||
return data != ""
|
return data != ""
|
||||||
}
|
}
|
||||||
|
|
@ -804,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
val data = postApi(q)
|
val data = postApi(q)
|
||||||
if (data.isNullOrBlank()) return null
|
if (data.isNullOrBlank()) return null
|
||||||
val userData = parseJson<AniListRoot>(data)
|
val userData = parseJson<AniListRoot>(data)
|
||||||
val u = userData.data?.Viewer
|
val u = userData.data?.viewer
|
||||||
val user = AniListUser(
|
val user = AniListUser(
|
||||||
u?.id,
|
u?.id,
|
||||||
u?.name,
|
u?.name,
|
||||||
|
|
@ -826,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
suspend fun getSeasonRecursive(id: Int) {
|
suspend fun getSeasonRecursive(id: Int) {
|
||||||
val season = getSeason(id)
|
val season = getSeason(id)
|
||||||
seasons.add(season)
|
seasons.add(season)
|
||||||
if (season.data.Media.format?.startsWith("TV") == true) {
|
if (season.data.media.format?.startsWith("TV") == true) {
|
||||||
season.data.Media.relations?.edges?.forEach {
|
season.data.media.relations?.edges?.forEach {
|
||||||
if (it.node?.format != null) {
|
if (it.node?.format != null) {
|
||||||
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
|
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
|
||||||
getSeasonRecursive(it.node.id)
|
getSeasonRecursive(it.node.id)
|
||||||
|
|
@ -846,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SeasonData(
|
data class SeasonData(
|
||||||
@JsonProperty("Media") val Media: SeasonMedia,
|
@JsonProperty("Media") val media: SeasonMedia,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SeasonMedia(
|
data class SeasonMedia(
|
||||||
|
|
@ -1018,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AniListData(
|
data class AniListData(
|
||||||
@JsonProperty("Viewer") val Viewer: AniListViewer?,
|
@JsonProperty("Viewer") val viewer: AniListViewer?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AniListRoot(
|
data class AniListRoot(
|
||||||
|
|
@ -1058,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LikeData(
|
data class LikeData(
|
||||||
@JsonProperty("Viewer") val Viewer: LikeViewer?,
|
@JsonProperty("Viewer") val viewer: LikeViewer?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class LikeRoot(
|
data class LikeRoot(
|
||||||
|
|
@ -1098,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GetDataData(
|
data class GetDataData(
|
||||||
@JsonProperty("Media") val Media: GetDataMedia?,
|
@JsonProperty("Media") val media: GetDataMedia?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GetDataRoot(
|
data class GetDataRoot(
|
||||||
|
|
@ -1131,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GetSearchPage(
|
data class GetSearchPage(
|
||||||
@JsonProperty("Page") val Page: GetSearchData?,
|
@JsonProperty("Page") val page: GetSearchData?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GetSearchData(
|
data class GetSearchData(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class Dropbox : OAuth2API {
|
||||||
override val key = "zlqsamadlwydvb2"
|
override val key = "zlqsamadlwydvb2"
|
||||||
override val redirectUrl = "dropboxlogin"
|
override val redirectUrl = "dropboxlogin"
|
||||||
override val requiresLogin = true
|
override val requiresLogin = true
|
||||||
|
override val supportDeviceAuth = false
|
||||||
override val createAccountUrl: String? = null
|
override val createAccountUrl: String? = null
|
||||||
|
|
||||||
override val icon: Int
|
override val icon: Int
|
||||||
|
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders.providers
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.lagradost.cloudstream3.TvType
|
|
||||||
import com.lagradost.cloudstream3.app
|
|
||||||
import com.lagradost.cloudstream3.imdbUrlToIdNullable
|
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
|
||||||
|
|
||||||
class IndexSubtitleApi : AbstractSubApi {
|
|
||||||
override val name = "IndexSubtitle"
|
|
||||||
override val idPrefix = "indexsubtitle"
|
|
||||||
override val requiresLogin = false
|
|
||||||
override val icon: Nothing? = null
|
|
||||||
override val createAccountUrl: Nothing? = null
|
|
||||||
|
|
||||||
override fun loginInfo(): Nothing? = null
|
|
||||||
|
|
||||||
override fun logOut() {}
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val host = "https://indexsubtitle.com"
|
|
||||||
const val TAG = "INDEXSUBS"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fixUrl(url: String): String {
|
|
||||||
if (url.startsWith("http")) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if (url.isEmpty()) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val startsWithNoHttp = url.startsWith("//")
|
|
||||||
if (startsWithNoHttp) {
|
|
||||||
return "https:$url"
|
|
||||||
} else {
|
|
||||||
if (url.startsWith('/')) {
|
|
||||||
return host + url
|
|
||||||
}
|
|
||||||
return "$host/$url"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrdinal(num: Int?): String? {
|
|
||||||
return when (num) {
|
|
||||||
1 -> "First"
|
|
||||||
2 -> "Second"
|
|
||||||
3 -> "Third"
|
|
||||||
4 -> "Fourth"
|
|
||||||
5 -> "Fifth"
|
|
||||||
6 -> "Sixth"
|
|
||||||
7 -> "Seventh"
|
|
||||||
8 -> "Eighth"
|
|
||||||
9 -> "Ninth"
|
|
||||||
10 -> "Tenth"
|
|
||||||
11 -> "Eleventh"
|
|
||||||
12 -> "Twelfth"
|
|
||||||
13 -> "Thirteenth"
|
|
||||||
14 -> "Fourteenth"
|
|
||||||
15 -> "Fifteenth"
|
|
||||||
16 -> "Sixteenth"
|
|
||||||
17 -> "Seventeenth"
|
|
||||||
18 -> "Eighteenth"
|
|
||||||
19 -> "Nineteenth"
|
|
||||||
20 -> "Twentieth"
|
|
||||||
21 -> "Twenty-First"
|
|
||||||
22 -> "Twenty-Second"
|
|
||||||
23 -> "Twenty-Third"
|
|
||||||
24 -> "Twenty-Fourth"
|
|
||||||
25 -> "Twenty-Fifth"
|
|
||||||
26 -> "Twenty-Sixth"
|
|
||||||
27 -> "Twenty-Seventh"
|
|
||||||
28 -> "Twenty-Eighth"
|
|
||||||
29 -> "Twenty-Ninth"
|
|
||||||
30 -> "Thirtieth"
|
|
||||||
31 -> "Thirty-First"
|
|
||||||
32 -> "Thirty-Second"
|
|
||||||
33 -> "Thirty-Third"
|
|
||||||
34 -> "Thirty-Fourth"
|
|
||||||
35 -> "Thirty-Fifth"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean {
|
|
||||||
val FILTER_EPS_REGEX =
|
|
||||||
Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))")
|
|
||||||
return text.contains(FILTER_EPS_REGEX)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun haveEps(text: String): Boolean {
|
|
||||||
val HAVE_EPS_REGEX =
|
|
||||||
Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))")
|
|
||||||
return text.contains(HAVE_EPS_REGEX)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity> {
|
|
||||||
val imdbId = query.imdb ?: 0
|
|
||||||
val lang = query.lang
|
|
||||||
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
|
|
||||||
val queryText = query.query
|
|
||||||
val epNum = query.epNumber ?: 0
|
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
|
||||||
val yearNum = query.year ?: 0
|
|
||||||
|
|
||||||
val urlItems = ArrayList<String>()
|
|
||||||
|
|
||||||
fun cleanResources(
|
|
||||||
results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
|
|
||||||
name: String,
|
|
||||||
link: String
|
|
||||||
) {
|
|
||||||
results.add(
|
|
||||||
AbstractSubtitleEntities.SubtitleEntity(
|
|
||||||
idPrefix = idPrefix,
|
|
||||||
name = name,
|
|
||||||
lang = queryLang.toString(),
|
|
||||||
data = link,
|
|
||||||
source = this.name,
|
|
||||||
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
|
|
||||||
epNumber = epNum,
|
|
||||||
seasonNumber = seasonNum,
|
|
||||||
year = yearNum,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val document = app.get("$host/?search=$queryText").document
|
|
||||||
|
|
||||||
document.select("div.my-3.p-3 div.media").map { block ->
|
|
||||||
if (seasonNum > 0) {
|
|
||||||
val name = block.select("strong.text-primary, strong.text-info").text().trim()
|
|
||||||
val season = getOrdinal(seasonNum)
|
|
||||||
if ((block.selectFirst("a")?.attr("href")
|
|
||||||
?.contains(
|
|
||||||
"$season",
|
|
||||||
ignoreCase = true
|
|
||||||
)!! || name.contains(
|
|
||||||
"$season",
|
|
||||||
ignoreCase = true
|
|
||||||
)) && name.contains(queryText, ignoreCase = true)
|
|
||||||
) {
|
|
||||||
block.select("div.media").mapNotNull {
|
|
||||||
urlItems.add(
|
|
||||||
fixUrl(
|
|
||||||
it.selectFirst("a")!!.attr("href")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (block.selectFirst("strong")!!.text().trim()
|
|
||||||
.matches(Regex("(?i)^$queryText\$"))
|
|
||||||
) {
|
|
||||||
if (block.select("span[title=Release]").isNullOrEmpty()) {
|
|
||||||
block.select("div.media").mapNotNull {
|
|
||||||
val urlItem = fixUrl(
|
|
||||||
it.selectFirst("a")!!.attr("href")
|
|
||||||
)
|
|
||||||
val itemDoc = app.get(urlItem).document
|
|
||||||
val id = imdbUrlToIdNullable(
|
|
||||||
itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent()
|
|
||||||
?.attr("href")
|
|
||||||
)?.toLongOrNull()
|
|
||||||
val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success")
|
|
||||||
?.ownText()
|
|
||||||
?.trim().toString()
|
|
||||||
Log.i(TAG, "id => $id \nyear => $year||$yearNum")
|
|
||||||
if (imdbId > 0) {
|
|
||||||
if (id == imdbId) {
|
|
||||||
urlItems.add(urlItem)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (year.contains("$yearNum")) {
|
|
||||||
urlItems.add(urlItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (block.select("span[title=Release]").text().trim()
|
|
||||||
.contains("$yearNum")
|
|
||||||
) {
|
|
||||||
block.select("div.media").mapNotNull {
|
|
||||||
urlItems.add(
|
|
||||||
fixUrl(
|
|
||||||
it.selectFirst("a")!!.attr("href")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.i(TAG, "urlItems => $urlItems")
|
|
||||||
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
|
|
||||||
|
|
||||||
urlItems.forEach { url ->
|
|
||||||
val request = app.get(url)
|
|
||||||
if (request.isSuccessful) {
|
|
||||||
request.document.select("div.my-3.p-3 div.media").map { block ->
|
|
||||||
if (block.select("span.d-block span[data-original-title=Language]").text()
|
|
||||||
.trim()
|
|
||||||
.contains("$queryLang")
|
|
||||||
) {
|
|
||||||
var name = block.select("strong.text-primary, strong.text-info").text().trim()
|
|
||||||
val link = fixUrl(block.selectFirst("a")!!.attr("href"))
|
|
||||||
if (seasonNum > 0) {
|
|
||||||
when {
|
|
||||||
isRightEps(name, seasonNum, epNum) -> {
|
|
||||||
cleanResources(results, name, link)
|
|
||||||
}
|
|
||||||
!(haveEps(name)) -> {
|
|
||||||
name = "$name (S${seasonNum}:E${epNum})"
|
|
||||||
cleanResources(results, name, link)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cleanResources(results, name, link)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
|
|
||||||
val seasonNum = data.seasonNumber
|
|
||||||
val epNum = data.epNumber
|
|
||||||
|
|
||||||
val req = app.get(data.data)
|
|
||||||
|
|
||||||
if (req.isSuccessful) {
|
|
||||||
val document = req.document
|
|
||||||
val link = if (document.select("div.my-3.p-3 div.media").size == 1) {
|
|
||||||
fixUrl(
|
|
||||||
document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
document.select("div.my-3.p-3 div.media").firstNotNullOf { block ->
|
|
||||||
val name =
|
|
||||||
block.selectFirst("strong.d-block")?.text()?.trim().toString()
|
|
||||||
if (seasonNum!! > 0) {
|
|
||||||
if (isRightEps(name, seasonNum, epNum)) {
|
|
||||||
fixUrl(block.selectFirst("a")!!.attr("href"))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fixUrl(block.selectFirst("a")!!.attr("href"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return link
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,11 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||||
|
|
@ -17,6 +21,7 @@ class LocalList : SyncAPI {
|
||||||
override val name = "Local"
|
override val name = "Local"
|
||||||
override val icon: Int = R.drawable.ic_baseline_storage_24
|
override val icon: Int = R.drawable.ic_baseline_storage_24
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
|
override val supportDeviceAuth = false
|
||||||
override val createAccountUrl: Nothing? = null
|
override val createAccountUrl: Nothing? = null
|
||||||
override val idPrefix = "local"
|
override val idPrefix = "local"
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
|
|
@ -44,11 +49,11 @@ class LocalList : SyncAPI {
|
||||||
|
|
||||||
override val mainUrl = ""
|
override val mainUrl = ""
|
||||||
override val syncIdName = SyncIdName.LocalList
|
override val syncIdName = SyncIdName.LocalList
|
||||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,28 +73,57 @@ class LocalList : SyncAPI {
|
||||||
}?.distinctBy { it.first } ?: return null
|
}?.distinctBy { it.first } ?: return null
|
||||||
|
|
||||||
val list = ioWork {
|
val list = ioWork {
|
||||||
watchStatusIds.groupBy {
|
val isTrueTv = isLayout(TV)
|
||||||
it.second.stringRes
|
|
||||||
}.mapValues { group ->
|
val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate {
|
||||||
|
// None is not something to display
|
||||||
|
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||||
|
} + mapOf(
|
||||||
|
R.string.favorites_list_name to emptyList()
|
||||||
|
) + if (!isTrueTv) {
|
||||||
|
mapOf(
|
||||||
|
R.string.subscription_list_name to emptyList()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group ->
|
||||||
group.value.mapNotNull {
|
group.value.mapNotNull {
|
||||||
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
|
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull {
|
||||||
|
it.toLibraryItem()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Don't show subscriptions on TV
|
||||||
|
val result = if (isTrueTv) {
|
||||||
|
baseMap + watchStatusMap + favoritesMap
|
||||||
|
} else {
|
||||||
|
val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
|
||||||
|
it.toLibraryItem()
|
||||||
|
})
|
||||||
|
|
||||||
|
baseMap + watchStatusMap + subscriptionsMap + favoritesMap
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate {
|
|
||||||
// None is not something to display
|
|
||||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
|
||||||
}
|
|
||||||
return SyncAPI.LibraryMetadata(
|
return SyncAPI.LibraryMetadata(
|
||||||
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
|
||||||
setOf(
|
setOf(
|
||||||
ListSorting.AlphabeticalA,
|
ListSorting.AlphabeticalA,
|
||||||
ListSorting.AlphabeticalZ,
|
ListSorting.AlphabeticalZ,
|
||||||
// ListSorting.UpdatedNew,
|
ListSorting.UpdatedNew,
|
||||||
// ListSorting.UpdatedOld,
|
ListSorting.UpdatedOld,
|
||||||
|
ListSorting.ReleaseDateNew,
|
||||||
|
ListSorting.ReleaseDateOld,
|
||||||
// ListSorting.RatingHigh,
|
// ListSorting.RatingHigh,
|
||||||
// ListSorting.RatingLow,
|
// ListSorting.RatingLow,
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,22 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
|
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
|
||||||
const val MAL_MAX_SEARCH_LIMIT = 25
|
const val MAL_MAX_SEARCH_LIMIT = 25
|
||||||
|
|
@ -39,6 +45,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
private val apiUrl = "https://api.myanimelist.net"
|
private val apiUrl = "https://api.myanimelist.net"
|
||||||
override val icon = R.drawable.mal_logo
|
override val icon = R.drawable.mal_logo
|
||||||
override val requiresLogin = false
|
override val requiresLogin = false
|
||||||
|
override val supportDeviceAuth = false
|
||||||
override val syncIdName = SyncIdName.MyAnimeList
|
override val syncIdName = SyncIdName.MyAnimeList
|
||||||
override var requireLibraryRefresh = true
|
override var requireLibraryRefresh = true
|
||||||
override val createAccountUrl = "$mainUrl/register.php"
|
override val createAccountUrl = "$mainUrl/register.php"
|
||||||
|
|
@ -49,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loginInfo(): AuthAPI.LoginInfo? {
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
//getMalUser(true)?
|
|
||||||
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
|
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
|
||||||
return AuthAPI.LoginInfo(
|
return AuthAPI.LoginInfo(
|
||||||
profilePicture = user.picture,
|
profilePicture = user.picture,
|
||||||
|
|
@ -82,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
this.name,
|
this.name,
|
||||||
node.id.toString(),
|
node.id.toString(),
|
||||||
"$mainUrl/anime/${node.id}/",
|
"$mainUrl/anime/${node.id}/",
|
||||||
node.main_picture?.large ?: node.main_picture?.medium
|
node.mainPicture?.large ?: node.mainPicture?.medium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,10 +97,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||||
return setScoreRequest(
|
return setScoreRequest(
|
||||||
id.toIntOrNull() ?: return false,
|
id.toIntOrNull() ?: return false,
|
||||||
fromIntToAnimeStatus(status.status),
|
fromIntToAnimeStatus(status.status.internalId),
|
||||||
status.score,
|
status.score,
|
||||||
status.watchedEpisodes
|
status.watchedEpisodes
|
||||||
).also {
|
).also {
|
||||||
|
|
@ -176,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
private fun parseDate(string: String?): Long? {
|
private fun parseDate(string: String?): Long? {
|
||||||
return try {
|
return try {
|
||||||
SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time
|
SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
apiName = this.name,
|
apiName = this.name,
|
||||||
syncId = node.id.toString(),
|
syncId = node.id.toString(),
|
||||||
url = "$mainUrl/anime/${node.id}",
|
url = "$mainUrl/anime/${node.id}",
|
||||||
posterUrl = node.main_picture?.large
|
posterUrl = node.mainPicture?.large
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,12 +248,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
val internalId = id.toIntOrNull() ?: return null
|
val internalId = id.toIntOrNull() ?: return null
|
||||||
|
|
||||||
val data =
|
val data =
|
||||||
getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status")
|
getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
|
||||||
return SyncAPI.SyncStatus(
|
return SyncAPI.SyncStatus(
|
||||||
score = data?.score,
|
score = data?.score,
|
||||||
status = malStatusAsString.indexOf(data?.status),
|
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
|
||||||
isFavorite = null,
|
isFavorite = null,
|
||||||
watchedEpisodes = data?.num_episodes_watched,
|
watchedEpisodes = data?.numEpisodesWatched,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +295,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
private fun parseDateLong(string: String?): Long? {
|
private fun parseDateLong(string: String?): Long? {
|
||||||
return try {
|
return try {
|
||||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse(
|
||||||
string ?: return null
|
string ?: return null
|
||||||
)?.time?.div(1000)
|
)?.time?.div(1000)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -300,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
override suspend fun handleRedirect(url: String): Boolean {
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
val sanitizer =
|
val sanitizer =
|
||||||
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
|
splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
|
||||||
val state = sanitizer["state"]!!
|
val state = sanitizer["state"]!!
|
||||||
if (state == "RequestID$requestId") {
|
if (state == "RequestID$requestId") {
|
||||||
val currentCode = sanitizer["code"]!!
|
val currentCode = sanitizer["code"]!!
|
||||||
|
|
@ -349,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
try {
|
try {
|
||||||
if (response != "") {
|
if (response != "") {
|
||||||
val token = parseJson<ResponseToken>(response)
|
val token = parseJson<ResponseToken>(response)
|
||||||
setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime))
|
setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime))
|
||||||
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token)
|
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken)
|
||||||
setKey(accountId, MAL_TOKEN_KEY, token.access_token)
|
setKey(accountId, MAL_TOKEN_KEY, token.accessToken)
|
||||||
requireLibraryRefresh = true
|
requireLibraryRefresh = true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -393,55 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
data class Node(
|
data class Node(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int,
|
||||||
@JsonProperty("title") val title: String,
|
@JsonProperty("title") val title: String,
|
||||||
@JsonProperty("main_picture") val main_picture: MainPicture?,
|
@JsonProperty("main_picture") val mainPicture: MainPicture?,
|
||||||
@JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?,
|
@JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
|
||||||
@JsonProperty("media_type") val media_type: String?,
|
@JsonProperty("media_type") val mediaType: String?,
|
||||||
@JsonProperty("num_episodes") val num_episodes: Int?,
|
@JsonProperty("num_episodes") val numEpisodes: Int?,
|
||||||
@JsonProperty("status") val status: String?,
|
@JsonProperty("status") val status: String?,
|
||||||
@JsonProperty("start_date") val start_date: String?,
|
@JsonProperty("start_date") val startDate: String?,
|
||||||
@JsonProperty("end_date") val end_date: String?,
|
@JsonProperty("end_date") val endDate: String?,
|
||||||
@JsonProperty("average_episode_duration") val average_episode_duration: Int?,
|
@JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
|
||||||
@JsonProperty("synopsis") val synopsis: String?,
|
@JsonProperty("synopsis") val synopsis: String?,
|
||||||
@JsonProperty("mean") val mean: Double?,
|
@JsonProperty("mean") val mean: Double?,
|
||||||
@JsonProperty("genres") val genres: List<Genres>?,
|
@JsonProperty("genres") val genres: List<Genres>?,
|
||||||
@JsonProperty("rank") val rank: Int?,
|
@JsonProperty("rank") val rank: Int?,
|
||||||
@JsonProperty("popularity") val popularity: Int?,
|
@JsonProperty("popularity") val popularity: Int?,
|
||||||
@JsonProperty("num_list_users") val num_list_users: Int?,
|
@JsonProperty("num_list_users") val numListUsers: Int?,
|
||||||
@JsonProperty("num_favorites") val num_favorites: Int?,
|
@JsonProperty("num_favorites") val numFavorites: Int?,
|
||||||
@JsonProperty("num_scoring_users") val num_scoring_users: Int?,
|
@JsonProperty("num_scoring_users") val numScoringUsers: Int?,
|
||||||
@JsonProperty("start_season") val start_season: StartSeason?,
|
@JsonProperty("start_season") val startSeason: StartSeason?,
|
||||||
@JsonProperty("broadcast") val broadcast: Broadcast?,
|
@JsonProperty("broadcast") val broadcast: Broadcast?,
|
||||||
@JsonProperty("nsfw") val nsfw: String?,
|
@JsonProperty("nsfw") val nsfw: String?,
|
||||||
@JsonProperty("created_at") val created_at: String?,
|
@JsonProperty("created_at") val createdAt: String?,
|
||||||
@JsonProperty("updated_at") val updated_at: String?
|
@JsonProperty("updated_at") val updatedAt: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ListStatus(
|
data class ListStatus(
|
||||||
@JsonProperty("status") val status: String?,
|
@JsonProperty("status") val status: String?,
|
||||||
@JsonProperty("score") val score: Int,
|
@JsonProperty("score") val score: Int,
|
||||||
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
|
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
|
||||||
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
|
@JsonProperty("is_rewatching") val isRewatching: Boolean,
|
||||||
@JsonProperty("updated_at") val updated_at: String,
|
@JsonProperty("updated_at") val updatedAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Data(
|
data class Data(
|
||||||
@JsonProperty("node") val node: Node,
|
@JsonProperty("node") val node: Node,
|
||||||
@JsonProperty("list_status") val list_status: ListStatus?,
|
@JsonProperty("list_status") val listStatus: ListStatus?,
|
||||||
) {
|
) {
|
||||||
fun toLibraryItem(): SyncAPI.LibraryItem {
|
fun toLibraryItem(): SyncAPI.LibraryItem {
|
||||||
return SyncAPI.LibraryItem(
|
return SyncAPI.LibraryItem(
|
||||||
this.node.title,
|
this.node.title,
|
||||||
"https://myanimelist.net/anime/${this.node.id}/",
|
"https://myanimelist.net/anime/${this.node.id}/",
|
||||||
this.node.id.toString(),
|
this.node.id.toString(),
|
||||||
this.list_status?.num_episodes_watched,
|
this.listStatus?.numEpisodesWatched,
|
||||||
this.node.num_episodes,
|
this.node.numEpisodes,
|
||||||
this.list_status?.score?.times(10),
|
this.listStatus?.score?.times(10),
|
||||||
parseDateLong(this.list_status?.updated_at),
|
parseDateLong(this.listStatus?.updatedAt),
|
||||||
"MAL",
|
"MAL",
|
||||||
TvType.Anime,
|
TvType.Anime,
|
||||||
this.node.main_picture?.large ?: this.node.main_picture?.medium,
|
this.node.mainPicture?.large ?: this.node.mainPicture?.medium,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
plot = this.node.synopsis,
|
||||||
|
releaseDate = if (this.node.startDate == null) null else try {Date.from(
|
||||||
|
Instant.from(
|
||||||
|
DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
|
||||||
|
.parse(this.node.startDate)
|
||||||
|
)
|
||||||
|
)} catch (_: RuntimeException) {null}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -467,8 +480,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Broadcast(
|
data class Broadcast(
|
||||||
@JsonProperty("day_of_the_week") val day_of_the_week: String?,
|
@JsonProperty("day_of_the_week") val dayOfTheWeek: String?,
|
||||||
@JsonProperty("start_time") val start_time: String?
|
@JsonProperty("start_time") val startTime: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun getMalAnimeListCached(): Array<Data>? {
|
private fun getMalAnimeListCached(): Array<Data>? {
|
||||||
|
|
@ -488,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
|
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
|
||||||
val list = getMalAnimeListSmart()?.groupBy {
|
val list = getMalAnimeListSmart()?.groupBy {
|
||||||
convertToStatus(it.list_status?.status ?: "").stringRes
|
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||||
}?.mapValues { group ->
|
}?.mapValues { group ->
|
||||||
group.value.map { it.toLibraryItem() }
|
group.value.map { it.toLibraryItem() }
|
||||||
} ?: emptyMap()
|
} ?: emptyMap()
|
||||||
|
|
||||||
// To fill empty lists when MAL does not return them
|
// To fill empty lists when MAL does not return them
|
||||||
val baseMap =
|
val baseMap =
|
||||||
MalStatusType.values().filter { it.value >= 0 }.associate {
|
MalStatusType.entries.filter { it.value >= 0 }.associate {
|
||||||
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
it.stringRes to emptyList<SyncAPI.LibraryItem>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
ListSorting.AlphabeticalZ,
|
ListSorting.AlphabeticalZ,
|
||||||
ListSorting.UpdatedNew,
|
ListSorting.UpdatedNew,
|
||||||
ListSorting.UpdatedOld,
|
ListSorting.UpdatedOld,
|
||||||
|
ListSorting.ReleaseDateNew,
|
||||||
|
ListSorting.ReleaseDateOld,
|
||||||
ListSorting.RatingHigh,
|
ListSorting.RatingHigh,
|
||||||
ListSorting.RatingLow,
|
ListSorting.RatingLow,
|
||||||
)
|
)
|
||||||
|
|
@ -570,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
).text
|
).text
|
||||||
val values = parseJson<MalRoot>(res)
|
val values = parseJson<MalRoot>(res)
|
||||||
val titles =
|
val titles =
|
||||||
values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) }
|
values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) }
|
||||||
for (t in titles) {
|
for (t in titles) {
|
||||||
allTitles[t.id] = t
|
allTitles[t.id] = t
|
||||||
}
|
}
|
||||||
|
|
@ -579,11 +594,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
|
private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
|
||||||
// No time remaining if the show has already ended
|
// No time remaining if the show has already ended
|
||||||
try {
|
try {
|
||||||
endDate?.let {
|
endDate?.let {
|
||||||
if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null
|
if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it)
|
||||||
|
?.before(Date.from(Instant.now())) != false
|
||||||
|
) return@convertJapanTimeToTimeRemaining null
|
||||||
}
|
}
|
||||||
} catch (e: ParseException) {
|
} catch (e: ParseException) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
|
@ -600,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
|
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
|
||||||
val currentYear = currentDate.get(Calendar.YEAR)
|
val currentYear = currentDate.get(Calendar.YEAR)
|
||||||
|
|
||||||
val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm")
|
val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault())
|
||||||
dateFormat.timeZone = TimeZone.getTimeZone("Japan")
|
dateFormat.timeZone = TimeZone.getTimeZone("Japan")
|
||||||
val parsedDate =
|
val parsedDate =
|
||||||
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
|
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
|
||||||
|
|
@ -644,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
id: Int,
|
id: Int,
|
||||||
status: MalStatusType? = null,
|
status: MalStatusType? = null,
|
||||||
score: Int? = null,
|
score: Int? = null,
|
||||||
num_watched_episodes: Int? = null,
|
numWatchedEpisodes: Int? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val res = setScoreRequest(
|
val res = setScoreRequest(
|
||||||
id,
|
id,
|
||||||
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
|
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
|
||||||
score,
|
score,
|
||||||
num_watched_episodes
|
numWatchedEpisodes
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (res.isNullOrBlank()) {
|
return if (res.isNullOrBlank()) {
|
||||||
|
|
@ -667,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
private suspend fun setScoreRequest(
|
private suspend fun setScoreRequest(
|
||||||
id: Int,
|
id: Int,
|
||||||
status: String? = null,
|
status: String? = null,
|
||||||
score: Int? = null,
|
score: Int? = null,
|
||||||
num_watched_episodes: Int? = null,
|
numWatchedEpisodes: Int? = null,
|
||||||
): String? {
|
): String? {
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
"status" to status,
|
"status" to status,
|
||||||
"score" to score?.toString(),
|
"score" to score?.toString(),
|
||||||
"num_watched_episodes" to num_watched_episodes?.toString()
|
"num_watched_episodes" to numWatchedEpisodes?.toString()
|
||||||
).filter { it.value != null } as Map<String, String>
|
).filterValues { it != null } as Map<String, String>
|
||||||
|
|
||||||
return app.put(
|
return app.put(
|
||||||
"$apiUrl/v2/anime/$id/my_list_status",
|
"$apiUrl/v2/anime/$id/my_list_status",
|
||||||
|
|
@ -690,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
|
|
||||||
data class ResponseToken(
|
data class ResponseToken(
|
||||||
@JsonProperty("token_type") val token_type: String,
|
@JsonProperty("token_type") val tokenType: String,
|
||||||
@JsonProperty("expires_in") val expires_in: Int,
|
@JsonProperty("expires_in") val expiresIn: Int,
|
||||||
@JsonProperty("access_token") val access_token: String,
|
@JsonProperty("access_token") val accessToken: String,
|
||||||
@JsonProperty("refresh_token") val refresh_token: String,
|
@JsonProperty("refresh_token") val refreshToken: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MalRoot(
|
data class MalRoot(
|
||||||
|
|
@ -702,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
|
|
||||||
data class MalDatum(
|
data class MalDatum(
|
||||||
@JsonProperty("node") val node: MalNode,
|
@JsonProperty("node") val node: MalNode,
|
||||||
@JsonProperty("list_status") val list_status: MalStatus,
|
@JsonProperty("list_status") val listStatus: MalStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MalNode(
|
data class MalNode(
|
||||||
|
|
@ -719,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
data class MalStatus(
|
data class MalStatus(
|
||||||
@JsonProperty("status") val status: String,
|
@JsonProperty("status") val status: String,
|
||||||
@JsonProperty("score") val score: Int,
|
@JsonProperty("score") val score: Int,
|
||||||
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
|
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
|
||||||
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
|
@JsonProperty("is_rewatching") val isRewatching: Boolean,
|
||||||
@JsonProperty("updated_at") val updated_at: String,
|
@JsonProperty("updated_at") val updatedAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MalUser(
|
data class MalUser(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int,
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
@JsonProperty("location") val location: String,
|
@JsonProperty("location") val location: String,
|
||||||
@JsonProperty("joined_at") val joined_at: String,
|
@JsonProperty("joined_at") val joinedAt: String,
|
||||||
@JsonProperty("picture") val picture: String?,
|
@JsonProperty("picture") val picture: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -741,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
||||||
data class SmallMalAnime(
|
data class SmallMalAnime(
|
||||||
@JsonProperty("id") val id: Int,
|
@JsonProperty("id") val id: Int,
|
||||||
@JsonProperty("title") val title: String?,
|
@JsonProperty("title") val title: String?,
|
||||||
@JsonProperty("num_episodes") val num_episodes: Int,
|
@JsonProperty("num_episodes") val numEpisodes: Int,
|
||||||
@JsonProperty("my_list_status") val my_list_status: MalStatus?,
|
@JsonProperty("my_list_status") val myListStatus: MalStatus?,
|
||||||
@JsonProperty("main_picture") val main_picture: MalMainPicture?,
|
@JsonProperty("main_picture") val mainPicture: MalMainPicture?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MalSearchNode(
|
data class MalSearchNode(
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.google.common.collect.BiMap
|
|
||||||
import com.google.common.collect.HashBiMap
|
|
||||||
import com.lagradost.cloudstream3.*
|
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
|
|
@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils
|
import com.lagradost.cloudstream3.utils.AppUtils
|
||||||
import java.net.URLEncoder
|
import okhttp3.Interceptor
|
||||||
import java.nio.charset.StandardCharsets
|
import okhttp3.Response
|
||||||
|
|
||||||
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||||
override val idPrefix = "opensubtitles"
|
override val idPrefix = "opensubtitles"
|
||||||
|
|
@ -28,14 +29,31 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
|
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
|
||||||
const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
|
const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
|
||||||
const val host = "https://api.opensubtitles.com/api/v1"
|
const val HOST = "https://api.opensubtitles.com/api/v1"
|
||||||
const val TAG = "OPENSUBS"
|
const val TAG = "OPENSUBS"
|
||||||
const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms
|
const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
|
||||||
var currentCoolDown: Long = 0L
|
var currentCoolDown: Long = 0L
|
||||||
var currentSession: SubtitleOAuthEntity? = null
|
var currentSession: SubtitleOAuthEntity? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val headerInterceptor = OpenSubtitleInterceptor()
|
||||||
|
|
||||||
|
/** Automatically adds required api headers */
|
||||||
|
private class OpenSubtitleInterceptor : Interceptor {
|
||||||
|
/** Required user agent! */
|
||||||
|
private val userAgent = "Cloudstream3 v0.1"
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
return chain.proceed(
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.removeHeader("user-agent")
|
||||||
|
.addHeader("user-agent", userAgent)
|
||||||
|
.addHeader("Api-Key", API_KEY)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun canDoRequest(): Boolean {
|
private fun canDoRequest(): Boolean {
|
||||||
return unixTimeMs > currentCoolDown
|
return unixTimeMs > currentCoolDown
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throwGotTooManyRequests() {
|
private fun throwGotTooManyRequests() {
|
||||||
currentCoolDown = unixTimeMs + coolDownDuration
|
currentCoolDown = unixTimeMs + COOLDOWN_DURATION
|
||||||
throw ErrorLoadingException("Too many requests")
|
throw ErrorLoadingException("Too many requests")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,15 +114,15 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
private suspend fun initLogin(username: String, password: String): Boolean {
|
private suspend fun initLogin(username: String, password: String): Boolean {
|
||||||
//Log.i(TAG, "DATA = [$username] [$password]")
|
//Log.i(TAG, "DATA = [$username] [$password]")
|
||||||
val response = app.post(
|
val response = app.post(
|
||||||
url = "$host/login",
|
url = "$HOST/login",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
"Api-Key" to apiKey,
|
"Content-Type" to "application/json",
|
||||||
"Content-Type" to "application/json"
|
|
||||||
),
|
),
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
"username" to username,
|
"username" to username,
|
||||||
"password" to password
|
"password" to password
|
||||||
)
|
),
|
||||||
|
interceptor = headerInterceptor
|
||||||
)
|
)
|
||||||
//Log.i(TAG, "Responsecode = ${response.code}")
|
//Log.i(TAG, "Responsecode = ${response.code}")
|
||||||
//Log.i(TAG, "Result => ${response.text}")
|
//Log.i(TAG, "Result => ${response.text}")
|
||||||
|
|
@ -115,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
SubtitleOAuthEntity(
|
SubtitleOAuthEntity(
|
||||||
user = username,
|
user = username,
|
||||||
pass = password,
|
pass = password,
|
||||||
access_token = token.token ?: run {
|
accessToken = token.token ?: run {
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
// "pt" to "pt-PT",
|
// "pt" to "pt-PT",
|
||||||
// "pt" to "pt-BR"
|
// "pt" to "pt-BR"
|
||||||
)
|
)
|
||||||
private fun fixLanguage(language: String?) : String? {
|
|
||||||
|
private fun fixLanguage(language: String?): String? {
|
||||||
return languageExceptions[language] ?: language
|
return languageExceptions[language] ?: language
|
||||||
}
|
}
|
||||||
|
|
||||||
// O(n) but good enough, BiMap did not want to work properly
|
// O(n) but good enough, BiMap did not want to work properly
|
||||||
private fun fixLanguageReverse(language: String?) : String? {
|
private fun fixLanguageReverse(language: String?): String? {
|
||||||
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
|
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +185,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
throwIfCantDoRequest()
|
throwIfCantDoRequest()
|
||||||
val fixedLang = fixLanguage(query.lang)
|
val fixedLang = fixLanguage(query.lang)
|
||||||
|
|
||||||
val imdbId = query.imdb ?: 0
|
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
|
||||||
val queryText = query.query
|
val queryText = query.query
|
||||||
val epNum = query.epNumber ?: 0
|
val epNum = query.epNumber ?: 0
|
||||||
val seasonNum = query.seasonNumber ?: 0
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
|
|
@ -176,16 +196,16 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
|
|
||||||
val searchQueryUrl = when (imdbId > 0) {
|
val searchQueryUrl = when (imdbId > 0) {
|
||||||
//Use imdb_id to search if its valid
|
//Use imdb_id to search if its valid
|
||||||
true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
|
||||||
}
|
}
|
||||||
|
|
||||||
val req = app.get(
|
val req = app.get(
|
||||||
url = searchQueryUrl,
|
url = searchQueryUrl,
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
Pair("Api-Key", apiKey),
|
|
||||||
Pair("Content-Type", "application/json")
|
Pair("Content-Type", "application/json")
|
||||||
)
|
),
|
||||||
|
interceptor = headerInterceptor
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Search Req => ${req.text}")
|
Log.i(TAG, "Search Req => ${req.text}")
|
||||||
if (!req.isSuccessful) {
|
if (!req.isSuccessful) {
|
||||||
|
|
@ -207,12 +227,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
//Use any valid name/title in hierarchy
|
//Use any valid name/title in hierarchy
|
||||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||||
val lang = fixLanguageReverse(attr.language)?: ""
|
val lang = fixLanguageReverse(attr.language) ?: ""
|
||||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||||
val year = featureDetails?.year ?: query.year
|
val year = featureDetails?.year ?: query.year
|
||||||
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
val isHearingImpaired = attr.hearing_impaired ?: false
|
val isHearingImpaired = attr.hearingImpaired ?: false
|
||||||
//Log.i(TAG, "Result id/name => ${item.id} / $name")
|
//Log.i(TAG, "Result id/name => ${item.id} / $name")
|
||||||
item.attributes?.files?.forEach { file ->
|
item.attributes?.files?.forEach { file ->
|
||||||
val resultData = file.fileId?.toString() ?: ""
|
val resultData = file.fileId?.toString() ?: ""
|
||||||
|
|
@ -245,19 +265,19 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
throwIfCantDoRequest()
|
throwIfCantDoRequest()
|
||||||
|
|
||||||
val req = app.post(
|
val req = app.post(
|
||||||
url = "$host/download",
|
url = "$HOST/download",
|
||||||
headers = mapOf(
|
headers = mapOf(
|
||||||
Pair(
|
Pair(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
|
"Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
|
||||||
),
|
),
|
||||||
Pair("Api-Key", apiKey),
|
|
||||||
Pair("Content-Type", "application/json"),
|
Pair("Content-Type", "application/json"),
|
||||||
Pair("Accept", "*/*")
|
Pair("Accept", "*/*")
|
||||||
),
|
),
|
||||||
data = mapOf(
|
data = mapOf(
|
||||||
Pair("file_id", data.data)
|
Pair("file_id", data.data)
|
||||||
)
|
),
|
||||||
|
interceptor = headerInterceptor
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
||||||
//Log.i(TAG, "Request headers => ${req.headers}")
|
//Log.i(TAG, "Request headers => ${req.headers}")
|
||||||
|
|
@ -278,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
data class SubtitleOAuthEntity(
|
data class SubtitleOAuthEntity(
|
||||||
var user: String,
|
var user: String,
|
||||||
var pass: String,
|
var pass: String,
|
||||||
var access_token: String,
|
var accessToken: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OAuthToken(
|
data class OAuthToken(
|
||||||
|
|
@ -303,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
@JsonProperty("url") var url: String? = null,
|
@JsonProperty("url") var url: String? = null,
|
||||||
@JsonProperty("files") var files: List<ResultFiles>? = listOf(),
|
@JsonProperty("files") var files: List<ResultFiles>? = listOf(),
|
||||||
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
|
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
|
||||||
@JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null,
|
@JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ResultFiles(
|
data class ResultFiles(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,159 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
|
|
||||||
|
class SubSourceApi : AbstractSubProvider {
|
||||||
|
override val idPrefix = "subsource"
|
||||||
|
val name = "SubSource"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val APIURL = "https://api.subsource.net/api"
|
||||||
|
const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
|
|
||||||
|
//Only supports Imdb Id search for now
|
||||||
|
if (query.imdbId == null) return null
|
||||||
|
val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
|
||||||
|
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
||||||
|
val searchRes = app.post(
|
||||||
|
url = "$APIURL/searchMovie",
|
||||||
|
data = mapOf(
|
||||||
|
"query" to query.imdbId!!
|
||||||
|
)
|
||||||
|
).parsedSafe<ApiSearch>() ?: return null
|
||||||
|
|
||||||
|
val postData = if (type == TvType.TvSeries) {
|
||||||
|
mapOf(
|
||||||
|
"langs" to "[]",
|
||||||
|
"movieName" to searchRes.found.first().linkName,
|
||||||
|
"season" to "season-${query.seasonNumber}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
mapOf(
|
||||||
|
"langs" to "[]",
|
||||||
|
"movieName" to searchRes.found.first().linkName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val getMovieRes = app.post(
|
||||||
|
url = "$APIURL/getMovie",
|
||||||
|
data = postData
|
||||||
|
).parsedSafe<ApiResponse>().let {
|
||||||
|
// api doesn't has episode number or lang filtering
|
||||||
|
if (type == TvType.Movie) {
|
||||||
|
it?.subs?.filter { sub ->
|
||||||
|
sub.lang == queryLang
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it?.subs?.filter { sub ->
|
||||||
|
sub.releaseName!!.contains(
|
||||||
|
String.format(
|
||||||
|
null,
|
||||||
|
"E%02d",
|
||||||
|
query.epNumber
|
||||||
|
)
|
||||||
|
) && sub.lang == queryLang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
return getMovieRes.map { subtitle ->
|
||||||
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
|
idPrefix = this.idPrefix,
|
||||||
|
name = subtitle.releaseName!!,
|
||||||
|
lang = subtitle.lang!!,
|
||||||
|
data = SubData(
|
||||||
|
movie = subtitle.linkName!!,
|
||||||
|
lang = subtitle.lang,
|
||||||
|
id = subtitle.subId.toString(),
|
||||||
|
).toJson(),
|
||||||
|
type = type,
|
||||||
|
source = this.name,
|
||||||
|
epNumber = query.epNumber,
|
||||||
|
seasonNumber = query.seasonNumber,
|
||||||
|
isHearingImpaired = subtitle.hi == 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
|
||||||
|
|
||||||
|
val parsedSub = parseJson<SubData>(data.data)
|
||||||
|
|
||||||
|
val subRes = app.post(
|
||||||
|
url = "$APIURL/getSub",
|
||||||
|
data = mapOf(
|
||||||
|
"movie" to parsedSub.movie,
|
||||||
|
"lang" to data.lang,
|
||||||
|
"id" to parsedSub.id
|
||||||
|
)
|
||||||
|
).parsedSafe<SubTitleLink>() ?: return
|
||||||
|
|
||||||
|
this.addZipUrl(
|
||||||
|
"$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
|
||||||
|
) { name, _ ->
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ApiSearch(
|
||||||
|
@JsonProperty("success") val success: Boolean,
|
||||||
|
@JsonProperty("found") val found: List<Found>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Found(
|
||||||
|
@JsonProperty("id") val id: Long,
|
||||||
|
@JsonProperty("title") val title: String,
|
||||||
|
@JsonProperty("seasons") val seasons: Long,
|
||||||
|
@JsonProperty("type") val type: String,
|
||||||
|
@JsonProperty("releaseYear") val releaseYear: Long,
|
||||||
|
@JsonProperty("linkName") val linkName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiResponse(
|
||||||
|
@JsonProperty("success") val success: Boolean,
|
||||||
|
@JsonProperty("movie") val movie: Movie,
|
||||||
|
@JsonProperty("subs") val subs: List<Sub>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Movie(
|
||||||
|
@JsonProperty("id") val id: Long? = null,
|
||||||
|
@JsonProperty("type") val type: String? = null,
|
||||||
|
@JsonProperty("year") val year: Long? = null,
|
||||||
|
@JsonProperty("fullName") val fullName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Sub(
|
||||||
|
@JsonProperty("hi") val hi: Int? = null,
|
||||||
|
@JsonProperty("fullLink") val fullLink: String? = null,
|
||||||
|
@JsonProperty("linkName") val linkName: String? = null,
|
||||||
|
@JsonProperty("lang") val lang: String? = null,
|
||||||
|
@JsonProperty("releaseName") val releaseName: String? = null,
|
||||||
|
@JsonProperty("subId") val subId: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SubData(
|
||||||
|
@JsonProperty("movie") val movie: String,
|
||||||
|
@JsonProperty("lang") val lang: String,
|
||||||
|
@JsonProperty("id") val id: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SubTitleLink(
|
||||||
|
@JsonProperty("sub") val sub: SubToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SubToken(
|
||||||
|
@JsonProperty("downloadToken") val downloadToken: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.app
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
|
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||||
|
|
||||||
|
class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||||
|
override val idPrefix = "subdl"
|
||||||
|
override val name = "SubDL"
|
||||||
|
override val icon = R.drawable.subdl_logo_big
|
||||||
|
override val requiresPassword = true
|
||||||
|
override val requiresEmail = true
|
||||||
|
override val createAccountUrl = "https://subdl.com/login"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val APIURL = "https://api.subdl.com"
|
||||||
|
const val APIENDPOINT = "$APIURL/api/v1/subtitles"
|
||||||
|
const val DOWNLOADENDPOINT = "https://dl.subdl.com"
|
||||||
|
const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
|
||||||
|
var currentSession: SubtitleOAuthEntity? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun initialize() {
|
||||||
|
currentSession = getAuthKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logOut() {
|
||||||
|
setAuthKey(null)
|
||||||
|
removeAccountKeys()
|
||||||
|
currentSession = getAuthKey()
|
||||||
|
}
|
||||||
|
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
|
||||||
|
val email = data.email ?: throw ErrorLoadingException("Requires Email")
|
||||||
|
val password = data.password ?: throw ErrorLoadingException("Requires Password")
|
||||||
|
switchToNewAccount()
|
||||||
|
try {
|
||||||
|
if (initLogin(email, password)) {
|
||||||
|
registerAccount()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
switchToOldAccount()
|
||||||
|
}
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
|
||||||
|
val current = getAuthKey() ?: return null
|
||||||
|
return InAppAuthAPI.LoginData(
|
||||||
|
email = current.userEmail,
|
||||||
|
password = current.pass
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loginInfo(): LoginInfo? {
|
||||||
|
getAuthKey()?.let { user ->
|
||||||
|
return LoginInfo(
|
||||||
|
profilePicture = null,
|
||||||
|
name = user.name ?: user.userEmail,
|
||||||
|
accountIndex = accountIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List<AbstractSubtitleEntities.SubtitleEntity>? {
|
||||||
|
|
||||||
|
val queryText = query.query
|
||||||
|
val epNum = query.epNumber ?: 0
|
||||||
|
val seasonNum = query.seasonNumber ?: 0
|
||||||
|
val yearNum = query.year ?: 0
|
||||||
|
|
||||||
|
val idQuery = when {
|
||||||
|
query.imdbId != null -> "&imdb_id=${query.imdbId}"
|
||||||
|
query.tmdbId != null -> "&tmdb_id=${query.tmdbId}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val epQuery = if (epNum > 0) "&episode_number=$epNum" else ""
|
||||||
|
val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else ""
|
||||||
|
val yearQuery = if (yearNum > 0) "&year=$yearNum" else ""
|
||||||
|
|
||||||
|
val searchQueryUrl = when (idQuery) {
|
||||||
|
//Use imdb/tmdb id to search if its valid
|
||||||
|
null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
|
else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
|
||||||
|
}
|
||||||
|
|
||||||
|
val req = app.get(
|
||||||
|
url = searchQueryUrl,
|
||||||
|
headers = mapOf(
|
||||||
|
"Accept" to "application/json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
|
||||||
|
|
||||||
|
val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
|
||||||
|
val resEpNum = subtitle.episode ?: query.epNumber
|
||||||
|
val resSeasonNum = subtitle.season ?: query.seasonNumber
|
||||||
|
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
|
||||||
|
|
||||||
|
AbstractSubtitleEntities.SubtitleEntity(
|
||||||
|
idPrefix = this.idPrefix,
|
||||||
|
name = subtitle.releaseName,
|
||||||
|
lang = lang,
|
||||||
|
data = "${DOWNLOADENDPOINT}${subtitle.url}",
|
||||||
|
type = type,
|
||||||
|
source = this.name,
|
||||||
|
epNumber = resEpNum,
|
||||||
|
seasonNumber = resSeasonNum,
|
||||||
|
isHearingImpaired = subtitle.hearingImpaired ?: false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
|
||||||
|
this.addZipUrl(data.data) { name, _ ->
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initLogin(useremail: String, password: String): Boolean {
|
||||||
|
|
||||||
|
val tokenResponse = app.post(
|
||||||
|
url = "$APIURL/login",
|
||||||
|
data = mapOf(
|
||||||
|
"email" to useremail,
|
||||||
|
"password" to password
|
||||||
|
)
|
||||||
|
).parsedSafe<OAuthTokenResponse>()
|
||||||
|
|
||||||
|
if (tokenResponse?.token == null) return false
|
||||||
|
|
||||||
|
val apiResponse = app.get(
|
||||||
|
url = "$APIURL/user/userApi",
|
||||||
|
headers = mapOf(
|
||||||
|
"Authorization" to "Bearer ${tokenResponse.token}"
|
||||||
|
)
|
||||||
|
).parsedSafe<ApiKeyResponse>()
|
||||||
|
|
||||||
|
if (apiResponse?.ok == false) return false
|
||||||
|
|
||||||
|
setAuthKey(
|
||||||
|
SubtitleOAuthEntity(
|
||||||
|
userEmail = useremail,
|
||||||
|
pass = password,
|
||||||
|
name = tokenResponse.userData?.username ?: tokenResponse.userData?.name,
|
||||||
|
accessToken = tokenResponse.token,
|
||||||
|
apiKey = apiResponse?.apiKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAuthKey(): SubtitleOAuthEntity? {
|
||||||
|
return getKey(accountId, SUBDL_SUBTITLES_USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAuthKey(data: SubtitleOAuthEntity?) {
|
||||||
|
if (data == null) removeKey(
|
||||||
|
accountId,
|
||||||
|
SUBDL_SUBTITLES_USER_KEY
|
||||||
|
)
|
||||||
|
currentSession = data
|
||||||
|
setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SubtitleOAuthEntity(
|
||||||
|
@JsonProperty("userEmail") var userEmail: String,
|
||||||
|
@JsonProperty("pass") var pass: String,
|
||||||
|
@JsonProperty("name") var name: String? = null,
|
||||||
|
@JsonProperty("accessToken") var accessToken: String? = null,
|
||||||
|
@JsonProperty("apiKey") var apiKey: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class OAuthTokenResponse(
|
||||||
|
@JsonProperty("token") val token: String? = null,
|
||||||
|
@JsonProperty("userData") val userData: UserData? = null,
|
||||||
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
|
@JsonProperty("message") val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserData(
|
||||||
|
@JsonProperty("email") val email: String,
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("country") val country: String,
|
||||||
|
@JsonProperty("scStepCode") val scStepCode: String,
|
||||||
|
@JsonProperty("scVerified") val scVerified: Boolean,
|
||||||
|
@JsonProperty("username") val username: String? = null,
|
||||||
|
@JsonProperty("scUsername") val scUsername: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiKeyResponse(
|
||||||
|
@JsonProperty("ok") val ok: Boolean? = false,
|
||||||
|
@JsonProperty("api_key") val apiKey: String? = null,
|
||||||
|
@JsonProperty("usage") val usage: Usage? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Usage(
|
||||||
|
@JsonProperty("total") val total: Long? = 0,
|
||||||
|
@JsonProperty("today") val today: Long? = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiResponse(
|
||||||
|
@JsonProperty("status") val status: Boolean? = null,
|
||||||
|
@JsonProperty("results") val results: List<Result>? = null,
|
||||||
|
@JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
@JsonProperty("sd_id") val sdId: Int? = null,
|
||||||
|
@JsonProperty("type") val type: String? = null,
|
||||||
|
@JsonProperty("name") val name: String? = null,
|
||||||
|
@JsonProperty("imdb_id") val imdbId: String? = null,
|
||||||
|
@JsonProperty("tmdb_id") val tmdbId: Long? = null,
|
||||||
|
@JsonProperty("first_air_date") val firstAirDate: String? = null,
|
||||||
|
@JsonProperty("year") val year: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Subtitle(
|
||||||
|
@JsonProperty("release_name") val releaseName: String,
|
||||||
|
@JsonProperty("name") val name: String,
|
||||||
|
@JsonProperty("lang") val lang: String,
|
||||||
|
@JsonProperty("author") val author: String? = null,
|
||||||
|
@JsonProperty("url") val url: String? = null,
|
||||||
|
@JsonProperty("subtitlePage") val subtitlePage: String? = null,
|
||||||
|
@JsonProperty("season") val season: Int? = null,
|
||||||
|
@JsonProperty("episode") val episode: Int? = null,
|
||||||
|
@JsonProperty("language") val language: String? = null,
|
||||||
|
@JsonProperty("hi") val hearingImpaired: Boolean? = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
package com.lagradost.cloudstream3.ui
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
import com.lagradost.cloudstream3.*
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||||
|
import com.lagradost.cloudstream3.DubStatus
|
||||||
|
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||||
|
import com.lagradost.cloudstream3.HomePageResponse
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||||
|
import com.lagradost.cloudstream3.MainPageRequest
|
||||||
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.SubtitleFile
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.fixUrl
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
|
||||||
import kotlinx.coroutines.GlobalScope.coroutineContext
|
import kotlinx.coroutines.GlobalScope.coroutineContext
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -42,7 +50,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
|
|
||||||
private val cache = threadSafeListOf<SavedLoadResponse>()
|
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||||
private var cacheIndex: Int = 0
|
private var cacheIndex: Int = 0
|
||||||
const val cacheSize = 20
|
const val CACHE_SIZE = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||||
|
|
@ -86,9 +94,9 @@ class APIRepository(val api: MainAPI) {
|
||||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||||
|
|
||||||
synchronized(cache) {
|
synchronized(cache) {
|
||||||
if (cache.size > cacheSize) {
|
if (cache.size > CACHE_SIZE) {
|
||||||
cache[cacheIndex] = add // rolling cache
|
cache[cacheIndex] = add // rolling cache
|
||||||
cacheIndex = (cacheIndex + 1) % cacheSize
|
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||||
} else {
|
} else {
|
||||||
cache.add(add)
|
cache.add(add)
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
|
||||||
data: String,
|
data: String,
|
||||||
isCasting: Boolean,
|
isCasting: Boolean,
|
||||||
subtitleCallback: (SubtitleFile) -> Unit,
|
subtitleCallback: (SubtitleFile) -> Unit,
|
||||||
callback: (ExtractorLink) -> Unit
|
callback: (ExtractorLink) -> Unit,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (isInvalidData(data)) return false // this makes providers cleaner
|
if (isInvalidData(data)) return false // this makes providers cleaner
|
||||||
return try {
|
return try {
|
||||||
|
|
|
||||||
252
app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt
Normal file
252
app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
|
||||||
|
open fun save(): T? = null
|
||||||
|
open fun restore(state: T) = Unit
|
||||||
|
open fun onViewAttachedToWindow() = Unit
|
||||||
|
open fun onViewDetachedFromWindow() = Unit
|
||||||
|
open fun onViewRecycled() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
|
||||||
|
class StateViewModel : ViewModel() {
|
||||||
|
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseAdapter is a persistent state stored adapter that supports headers and footers.
|
||||||
|
* This should be used for restoring eg scroll or focus related to a view when it is recreated.
|
||||||
|
*
|
||||||
|
* Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel.
|
||||||
|
*
|
||||||
|
* diffCallback is how the view should be handled when updating, override onUpdateContent for updates
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
*
|
||||||
|
* By default it should save automatically, but you can also call save(recycle)
|
||||||
|
*
|
||||||
|
* By default no state is stored, but doing an id != 0 will store
|
||||||
|
*
|
||||||
|
* By default no headers or footers exist, override footers and headers count
|
||||||
|
*/
|
||||||
|
abstract class BaseAdapter<
|
||||||
|
T : Any,
|
||||||
|
S : Any>(
|
||||||
|
fragment: Fragment,
|
||||||
|
val id: Int = 0,
|
||||||
|
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
|
||||||
|
) : RecyclerView.Adapter<ViewHolderState<S>>() {
|
||||||
|
open val footers: Int = 0
|
||||||
|
open val headers: Int = 0
|
||||||
|
|
||||||
|
fun getItem(position: Int): T {
|
||||||
|
return mDiffer.currentList[position]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getItemOrNull(position: Int): T? {
|
||||||
|
return mDiffer.currentList.getOrNull(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mDiffer: AsyncListDiffer<T> = AsyncListDiffer(
|
||||||
|
object : NonFinalAdapterListUpdateCallback(this) {
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||||
|
super.onMoved(fromPosition + headers, toPosition + headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int) {
|
||||||
|
super.onRemoved(position + headers, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
super.onChanged(position + headers, count, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onInserted(position: Int, count: Int) {
|
||||||
|
super.onInserted(position + headers, count)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AsyncDifferConfig.Builder(diffCallback).build()
|
||||||
|
)
|
||||||
|
|
||||||
|
open fun submitList(list: List<T>?) {
|
||||||
|
// deep copy at least the top list, because otherwise adapter can go crazy
|
||||||
|
mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return mDiffer.currentList.size + footers + headers
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onUpdateContent(holder: ViewHolderState<S>, item: T, position: Int) =
|
||||||
|
onBindContent(holder, item, position)
|
||||||
|
|
||||||
|
open fun onBindContent(holder: ViewHolderState<S>, item: T, position: Int) = Unit
|
||||||
|
open fun onBindFooter(holder: ViewHolderState<S>) = Unit
|
||||||
|
open fun onBindHeader(holder: ViewHolderState<S>) = Unit
|
||||||
|
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
|
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
|
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
|
||||||
|
|
||||||
|
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
|
||||||
|
holder.onViewAttachedToWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
|
||||||
|
holder.onViewDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
fun save(recyclerView: RecyclerView) {
|
||||||
|
for (child in recyclerView.children) {
|
||||||
|
val holder =
|
||||||
|
recyclerView.findContainingViewHolder(child) as? ViewHolderState<S> ?: continue
|
||||||
|
setState(holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
stateViewModel.layoutManagerStates[id]?.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun getState(holder: ViewHolderState<S>): S? =
|
||||||
|
stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
|
||||||
|
|
||||||
|
private fun setState(holder: ViewHolderState<S>) {
|
||||||
|
if(id == 0) return
|
||||||
|
|
||||||
|
if (!stateViewModel.layoutManagerStates.contains(id)) {
|
||||||
|
stateViewModel.layoutManagerStates[id] = HashMap()
|
||||||
|
}
|
||||||
|
stateViewModel.layoutManagerStates[id]?.let { map ->
|
||||||
|
map[holder.absoluteAdapterPosition] = holder.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val attachListener = object : View.OnAttachStateChangeListener {
|
||||||
|
override fun onViewAttachedToWindow(v: View) = Unit
|
||||||
|
override fun onViewDetachedFromWindow(v: View) {
|
||||||
|
if (v !is RecyclerView) return
|
||||||
|
save(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
recyclerView.addOnAttachStateChangeListener(attachListener)
|
||||||
|
super.onAttachedToRecyclerView(recyclerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||||
|
recyclerView.removeOnAttachStateChangeListener(attachListener)
|
||||||
|
super.onDetachedFromRecyclerView(recyclerView)
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getItemViewType(position: Int): Int {
|
||||||
|
if (position < headers) {
|
||||||
|
return HEADER
|
||||||
|
}
|
||||||
|
if (position - headers >= mDiffer.currentList.size) {
|
||||||
|
return FOOTER
|
||||||
|
}
|
||||||
|
|
||||||
|
return CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stateViewModel: StateViewModel by fragment.viewModels()
|
||||||
|
|
||||||
|
final override fun onViewRecycled(holder: ViewHolderState<S>) {
|
||||||
|
setState(holder)
|
||||||
|
holder.onViewRecycled()
|
||||||
|
super.onViewRecycled(holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
|
||||||
|
return when (viewType) {
|
||||||
|
CONTENT -> onCreateContent(parent)
|
||||||
|
HEADER -> onCreateHeader(parent)
|
||||||
|
FOOTER -> onCreateFooter(parent)
|
||||||
|
else -> throw NotImplementedError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068
|
||||||
|
override fun onBindViewHolder(
|
||||||
|
holder: ViewHolderState<S>,
|
||||||
|
position: Int,
|
||||||
|
payloads: MutableList<Any>
|
||||||
|
) {
|
||||||
|
if (payloads.isEmpty()) {
|
||||||
|
super.onBindViewHolder(holder, position, payloads)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (getItemViewType(position)) {
|
||||||
|
CONTENT -> {
|
||||||
|
val realPosition = position - headers
|
||||||
|
val item = getItem(realPosition)
|
||||||
|
onUpdateContent(holder, item, realPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOTER -> {
|
||||||
|
onBindFooter(holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADER -> {
|
||||||
|
onBindHeader(holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
|
||||||
|
when (getItemViewType(position)) {
|
||||||
|
CONTENT -> {
|
||||||
|
val realPosition = position - headers
|
||||||
|
val item = getItem(realPosition)
|
||||||
|
onBindContent(holder, item, realPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOTER -> {
|
||||||
|
onBindFooter(holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADER -> {
|
||||||
|
onBindHeader(holder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(holder)?.let { state ->
|
||||||
|
holder.restore(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val HEADER: Int = 1
|
||||||
|
private const val FOOTER: Int = 2
|
||||||
|
private const val CONTENT: Int = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseDiffCallback<T : Any>(
|
||||||
|
val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() },
|
||||||
|
val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }
|
||||||
|
) : DiffUtil.ItemCallback<T>() {
|
||||||
|
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
|
||||||
|
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
|
||||||
|
override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,10 @@ import android.view.Menu
|
||||||
import android.view.View.*
|
import android.view.View.*
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.cast.MediaSeekOptions
|
import com.google.android.gms.cast.MediaSeekOptions
|
||||||
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
|
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
|
||||||
|
|
@ -23,12 +24,13 @@ import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||||
import com.lagradost.cloudstream3.sortSubs
|
|
||||||
import com.lagradost.cloudstream3.sortUrls
|
import com.lagradost.cloudstream3.sortUrls
|
||||||
|
import com.lagradost.cloudstream3.ui.player.LoadType
|
||||||
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
|
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
|
||||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
|
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
|
||||||
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
|
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
|
||||||
|
|
@ -97,7 +99,7 @@ data class MetadataHolder(
|
||||||
|
|
||||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||||
UIController() {
|
UIController() {
|
||||||
private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
|
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -262,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
|
|
||||||
var isLoadingMore = false
|
var isLoadingMore = false
|
||||||
|
|
||||||
|
|
||||||
override fun onMediaStatusUpdated() {
|
override fun onMediaStatusUpdated() {
|
||||||
super.onMediaStatusUpdated()
|
super.onMediaStatusUpdated()
|
||||||
val meta = getCurrentMetaData()
|
val meta = getCurrentMetaData()
|
||||||
|
|
@ -294,7 +297,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
||||||
val generator = RepoLinkGenerator(listOf(epData))
|
val generator = RepoLinkGenerator(listOf(epData))
|
||||||
|
|
||||||
val isSuccessful = safeApiCall {
|
val isSuccessful = safeApiCall {
|
||||||
generator.generateLinks(clearCache = false, isCasting = true,
|
generator.generateLinks(
|
||||||
|
clearCache = false, type = LoadType.Chromecast,
|
||||||
callback = {
|
callback = {
|
||||||
it.first?.let { link ->
|
it.first?.let { link ->
|
||||||
currentLinks.add(link)
|
currentLinks.add(link)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
class GrdLayoutManager(val context: Context, spanCount: Int) :
|
||||||
GridLayoutManager(context, _spanCount) {
|
GridLayoutManager(context, spanCount) {
|
||||||
override fun onFocusSearchFailed(
|
override fun onFocusSearchFailed(
|
||||||
focused: View,
|
focused: View,
|
||||||
focusDirection: Int,
|
focusDirection: Int,
|
||||||
|
|
@ -24,7 +25,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRequestChildFocus(
|
/*override fun onRequestChildFocus(
|
||||||
parent: RecyclerView,
|
parent: RecyclerView,
|
||||||
state: RecyclerView.State,
|
state: RecyclerView.State,
|
||||||
child: View,
|
child: View,
|
||||||
|
|
@ -32,13 +33,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams
|
// android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams
|
||||||
return try {
|
return try {
|
||||||
val pos = maxOf(0, getPosition(focused!!) - 2)
|
if(focused != null) {
|
||||||
parent.scrollToPosition(pos)
|
// val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY
|
||||||
|
val pos = getPosition(focused)
|
||||||
|
if(pos >= 0) parent.scrollToPosition(pos)
|
||||||
|
}
|
||||||
|
|
||||||
super.onRequestChildFocus(parent, state, child, focused)
|
super.onRequestChildFocus(parent, state, child, focused)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
// Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d
|
// Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d
|
||||||
override fun onInterceptFocusSearch(focused: View, direction: Int): View? {
|
override fun onInterceptFocusSearch(focused: View, direction: Int): View? {
|
||||||
|
|
@ -65,32 +70,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
||||||
val spanCount = this.spanCount
|
val spanCount = this.spanCount
|
||||||
val orientation = this.orientation
|
val orientation = this.orientation
|
||||||
|
|
||||||
if (orientation == VERTICAL) {
|
// fixes arabic by inverting left and right layout focus
|
||||||
|
val correctDirection = if (this.isLayoutRTL) {
|
||||||
when (direction) {
|
when (direction) {
|
||||||
|
View.FOCUS_RIGHT -> View.FOCUS_LEFT
|
||||||
|
View.FOCUS_LEFT -> View.FOCUS_RIGHT
|
||||||
|
else -> direction
|
||||||
|
}
|
||||||
|
} else direction
|
||||||
|
|
||||||
|
if (orientation == VERTICAL) {
|
||||||
|
when (correctDirection) {
|
||||||
View.FOCUS_DOWN -> {
|
View.FOCUS_DOWN -> {
|
||||||
return spanCount
|
return spanCount
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_UP -> {
|
View.FOCUS_UP -> {
|
||||||
return -spanCount
|
return -spanCount
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_RIGHT -> {
|
View.FOCUS_RIGHT -> {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_LEFT -> {
|
View.FOCUS_LEFT -> {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (orientation == HORIZONTAL) {
|
} else if (orientation == HORIZONTAL) {
|
||||||
when (direction) {
|
when (correctDirection) {
|
||||||
View.FOCUS_DOWN -> {
|
View.FOCUS_DOWN -> {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_UP -> {
|
View.FOCUS_UP -> {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_RIGHT -> {
|
View.FOCUS_RIGHT -> {
|
||||||
return spanCount
|
return spanCount
|
||||||
}
|
}
|
||||||
|
|
||||||
View.FOCUS_LEFT -> {
|
View.FOCUS_LEFT -> {
|
||||||
return -spanCount
|
return -spanCount
|
||||||
}
|
}
|
||||||
|
|
@ -143,3 +163,31 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
||||||
layoutManager = manager
|
layoutManager = manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes.
|
||||||
|
*/
|
||||||
|
class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) {
|
||||||
|
private var biggestObserved: Int = 0
|
||||||
|
private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation
|
||||||
|
private val isHorizontal = orientation == HORIZONTAL
|
||||||
|
private fun View.updateMaxSize() {
|
||||||
|
if (isHorizontal) {
|
||||||
|
this.minimumHeight = biggestObserved
|
||||||
|
} else {
|
||||||
|
this.minimumWidth = biggestObserved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onChildAttachedToWindow(child: View) {
|
||||||
|
child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
|
||||||
|
val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth
|
||||||
|
if (observed > biggestObserved) {
|
||||||
|
biggestObserved = observed
|
||||||
|
children.forEach { it.updateMaxSize() }
|
||||||
|
} else {
|
||||||
|
child.updateMaxSize()
|
||||||
|
}
|
||||||
|
super.onChildAttachedToWindow(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,14 +16,16 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.AppCompatImageView
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import kotlinx.android.synthetic.main.activity_easter_egg_monke.*
|
import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class EasterEggMonke : AppCompatActivity() {
|
class EasterEggMonke : AppCompatActivity() {
|
||||||
|
|
||||||
|
lateinit var binding : ActivityEasterEggMonkeBinding
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_easter_egg_monke)
|
|
||||||
|
binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
val handler = Handler(mainLooper)
|
val handler = Handler(mainLooper)
|
||||||
lateinit var runnable: Runnable
|
lateinit var runnable: Runnable
|
||||||
|
|
@ -32,15 +34,14 @@ class EasterEggMonke : AppCompatActivity() {
|
||||||
handler.postDelayed(runnable, 300)
|
handler.postDelayed(runnable, 300)
|
||||||
}
|
}
|
||||||
handler.postDelayed(runnable, 1000)
|
handler.postDelayed(runnable, 1000)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shower() {
|
private fun shower() {
|
||||||
|
|
||||||
val containerW = frame.width
|
val containerW = binding.frame.width
|
||||||
val containerH = frame.height
|
val containerH = binding.frame.height
|
||||||
var starW: Float = monke.width.toFloat()
|
var starW: Float = binding.monke.width.toFloat()
|
||||||
var starH: Float = monke.height.toFloat()
|
var starH: Float = binding.monke.height.toFloat()
|
||||||
|
|
||||||
val newStar = AppCompatImageView(this)
|
val newStar = AppCompatImageView(this)
|
||||||
val idx = (monkeys.size * Math.random()).toInt()
|
val idx = (monkeys.size * Math.random()).toInt()
|
||||||
|
|
@ -48,9 +49,9 @@ class EasterEggMonke : AppCompatActivity() {
|
||||||
newStar.isVisible = true
|
newStar.isVisible = true
|
||||||
newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
|
newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT)
|
FrameLayout.LayoutParams.WRAP_CONTENT)
|
||||||
frame.addView(newStar)
|
binding.frame.addView(newStar)
|
||||||
|
|
||||||
newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX
|
newStar.scaleX += Math.random().toFloat() * 1.5f
|
||||||
newStar.scaleY = newStar.scaleX
|
newStar.scaleY = newStar.scaleX
|
||||||
starW *= newStar.scaleX
|
starW *= newStar.scaleX
|
||||||
starH *= newStar.scaleY
|
starH *= newStar.scaleY
|
||||||
|
|
@ -70,7 +71,7 @@ class EasterEggMonke : AppCompatActivity() {
|
||||||
|
|
||||||
set.addListener(object : AnimatorListenerAdapter() {
|
set.addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
frame.removeView(newStar)
|
binding.frame.removeView(newStar)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.lagradost.cloudstream3.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListUpdateCallback
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ListUpdateCallback that dispatches update events to the given adapter.
|
||||||
|
*
|
||||||
|
* @see DiffUtil.DiffResult.dispatchUpdatesTo
|
||||||
|
*/
|
||||||
|
open class NonFinalAdapterListUpdateCallback
|
||||||
|
/**
|
||||||
|
* Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter.
|
||||||
|
*
|
||||||
|
* @param mAdapter The Adapter to send updates to.
|
||||||
|
*/(private var mAdapter: RecyclerView.Adapter<*>) :
|
||||||
|
ListUpdateCallback {
|
||||||
|
|
||||||
|
override fun onInserted(position: Int, count: Int) {
|
||||||
|
mAdapter.notifyItemRangeInserted(position, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(position: Int, count: Int) {
|
||||||
|
mAdapter.notifyItemRangeRemoved(position, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
||||||
|
mAdapter.notifyItemMoved(fromPosition, toPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly
|
||||||
|
override fun onChanged(position: Int, count: Int, payload: Any?) {
|
||||||
|
mAdapter.notifyItemRangeChanged(position, count, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,6 +13,29 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab
|
||||||
NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24);
|
NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE
|
fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
|
||||||
|
/*
|
||||||
|
-1 -> None
|
||||||
|
0 -> Watching
|
||||||
|
1 -> Completed
|
||||||
|
2 -> OnHold
|
||||||
|
3 -> Dropped
|
||||||
|
4 -> PlanToWatch
|
||||||
|
5 -> ReWatching
|
||||||
|
*/
|
||||||
|
NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24),
|
||||||
|
WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24),
|
||||||
|
COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24),
|
||||||
|
ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24),
|
||||||
|
DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24),
|
||||||
|
PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24),
|
||||||
|
REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,24 +8,30 @@ import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.lagradost.cloudstream3.MainActivity
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.USER_AGENT
|
import com.lagradost.cloudstream3.USER_AGENT
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
|
||||||
import com.lagradost.cloudstream3.network.WebViewResolver
|
import com.lagradost.cloudstream3.network.WebViewResolver
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
|
||||||
import kotlinx.android.synthetic.main.fragment_webview.*
|
|
||||||
|
|
||||||
class WebviewFragment : Fragment() {
|
class WebviewFragment : Fragment() {
|
||||||
|
|
||||||
|
var binding: FragmentWebviewBinding? = null
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
|
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
|
||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
web_view.webViewClient = object : WebViewClient() {
|
binding?.webView?.webViewClient = object : WebViewClient() {
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?
|
request: WebResourceRequest?
|
||||||
|
|
@ -40,24 +46,28 @@ class WebviewFragment : Fragment() {
|
||||||
return super.shouldOverrideUrlLoading(view, request)
|
return super.shouldOverrideUrlLoading(view, request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding?.webView?.apply {
|
||||||
|
WebViewResolver.webViewUserAgent = settings.userAgentString
|
||||||
|
|
||||||
WebViewResolver.webViewUserAgent = web_view.settings.userAgentString
|
addJavascriptInterface(RepoApi(activity), "RepoApi")
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
web_view.addJavascriptInterface(RepoApi(activity), "RepoApi")
|
settings.userAgentString = USER_AGENT
|
||||||
web_view.settings.javaScriptEnabled = true
|
settings.domStorageEnabled = true
|
||||||
web_view.settings.userAgentString = USER_AGENT
|
|
||||||
web_view.settings.domStorageEnabled = true
|
|
||||||
// WebView.setWebContentsDebuggingEnabled(true)
|
// WebView.setWebContentsDebuggingEnabled(true)
|
||||||
|
|
||||||
web_view.loadUrl(url)
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater, container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View {
|
||||||
|
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
|
||||||
|
binding = localBinding
|
||||||
// Inflate the layout for this fragment
|
// Inflate the layout for this fragment
|
||||||
return inflater.inflate(R.layout.fragment_webview, container, false)
|
return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -70,7 +80,7 @@ class WebviewFragment : Fragment() {
|
||||||
|
|
||||||
private class RepoApi(val activity: FragmentActivity?) {
|
private class RepoApi(val activity: FragmentActivity?) {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun installRepo(repoUrl: String) {
|
fun installRepo(repoUrl: String) {
|
||||||
activity?.loadRepository(repoUrl)
|
activity?.loadRepository(repoUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.AccountListItemBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
|
||||||
|
import com.lagradost.cloudstream3.ui.result.setImage
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||||
|
|
||||||
|
class AccountAdapter(
|
||||||
|
private val accounts: List<DataStoreHelper.Account>,
|
||||||
|
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
|
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
|
private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
|
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
|
||||||
|
) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VIEW_TYPE_SELECT_ACCOUNT = 0
|
||||||
|
const val VIEW_TYPE_ADD_ACCOUNT = 1
|
||||||
|
const val VIEW_TYPE_EDIT_ACCOUNT = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class AccountViewHolder(private val binding: ViewBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(account: DataStoreHelper.Account?) {
|
||||||
|
when (binding) {
|
||||||
|
is AccountListItemBinding -> binding.apply {
|
||||||
|
if (account == null) return@apply
|
||||||
|
|
||||||
|
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||||
|
|
||||||
|
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
|
||||||
|
accountName.text = account.name
|
||||||
|
accountImage.setImage(account.image)
|
||||||
|
lockIcon.isVisible = account.lockPin != null
|
||||||
|
outline.isVisible = !isTv && isLastUsedAccount
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// For emulator but this is fine on TV also
|
||||||
|
root.isFocusableInTouchMode = true
|
||||||
|
if (isLastUsedAccount) {
|
||||||
|
root.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
root.foreground = ContextCompat.getDrawable(
|
||||||
|
root.context,
|
||||||
|
R.drawable.outline_drawable
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
root.setOnLongClickListener {
|
||||||
|
showAccountEditDialog(
|
||||||
|
context = root.context,
|
||||||
|
account = account,
|
||||||
|
isNewAccount = false,
|
||||||
|
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||||
|
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.setOnClickListener {
|
||||||
|
accountSelectCallback.invoke(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AccountListItemEditBinding -> binding.apply {
|
||||||
|
if (account == null) return@apply
|
||||||
|
|
||||||
|
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
|
||||||
|
|
||||||
|
val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
|
||||||
|
accountName.text = account.name
|
||||||
|
accountImage.setImage(
|
||||||
|
account.image,
|
||||||
|
fadeIn = false,
|
||||||
|
radius = 10
|
||||||
|
)
|
||||||
|
lockIcon.isVisible = account.lockPin != null
|
||||||
|
outline.isVisible = !isTv && isLastUsedAccount
|
||||||
|
|
||||||
|
if (isTv) {
|
||||||
|
// For emulator but this is fine on TV also
|
||||||
|
root.isFocusableInTouchMode = true
|
||||||
|
if (isLastUsedAccount) {
|
||||||
|
root.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
root.foreground = ContextCompat.getDrawable(
|
||||||
|
root.context,
|
||||||
|
R.drawable.outline_drawable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
root.setOnClickListener {
|
||||||
|
showAccountEditDialog(
|
||||||
|
context = root.context,
|
||||||
|
account = account,
|
||||||
|
isNewAccount = false,
|
||||||
|
accountEditCallback = { account -> accountEditCallback.invoke(account) },
|
||||||
|
accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AccountListItemAddBinding -> binding.apply {
|
||||||
|
root.setOnClickListener {
|
||||||
|
val remainingImages =
|
||||||
|
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
|
||||||
|
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
|
||||||
|
|
||||||
|
val image =
|
||||||
|
DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
|
||||||
|
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
|
||||||
|
|
||||||
|
val accountName = root.context.getString(R.string.account)
|
||||||
|
|
||||||
|
showAccountEditDialog(
|
||||||
|
root.context,
|
||||||
|
DataStoreHelper.Account(
|
||||||
|
keyIndex = keyIndex,
|
||||||
|
name = "$accountName $keyIndex",
|
||||||
|
customImage = null,
|
||||||
|
defaultImageIndex = image
|
||||||
|
),
|
||||||
|
isNewAccount = true,
|
||||||
|
accountEditCallback = { account -> accountCreateCallback.invoke(account) },
|
||||||
|
accountDeleteCallback = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
|
||||||
|
AccountViewHolder(
|
||||||
|
binding = when (viewType) {
|
||||||
|
VIEW_TYPE_SELECT_ACCOUNT -> {
|
||||||
|
AccountListItemBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VIEW_TYPE_ADD_ACCOUNT -> {
|
||||||
|
AccountListItemAddBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VIEW_TYPE_EDIT_ACCOUNT -> {
|
||||||
|
AccountListItemEditBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Invalid view type")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
|
||||||
|
holder.bind(accounts.getOrNull(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewType = 0
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
if (viewType != 0 && position != accounts.count()) {
|
||||||
|
return viewType
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (position) {
|
||||||
|
accounts.count() -> VIEW_TYPE_ADD_ACCOUNT
|
||||||
|
else -> VIEW_TYPE_SELECT_ACCOUNT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return accounts.count() + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.text.Editable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.LockPinDialogBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
|
import com.lagradost.cloudstream3.ui.result.setImage
|
||||||
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
|
||||||
|
|
||||||
|
object AccountHelper {
|
||||||
|
fun showAccountEditDialog(
|
||||||
|
context: Context,
|
||||||
|
account: DataStoreHelper.Account,
|
||||||
|
isNewAccount: Boolean,
|
||||||
|
accountEditCallback: (DataStoreHelper.Account) -> Unit,
|
||||||
|
accountDeleteCallback: (DataStoreHelper.Account) -> Unit
|
||||||
|
) {
|
||||||
|
val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false)
|
||||||
|
val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
|
||||||
|
.setView(binding.root)
|
||||||
|
|
||||||
|
var currentEditAccount = account
|
||||||
|
val dialog = builder.show()
|
||||||
|
|
||||||
|
if (!isNewAccount) binding.title.setText(R.string.edit_account)
|
||||||
|
|
||||||
|
// Set up the dialog content
|
||||||
|
binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name)
|
||||||
|
binding.accountName.doOnTextChanged { text, _, _, _ ->
|
||||||
|
currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.deleteBtt.isGone = isNewAccount
|
||||||
|
binding.deleteBtt.setOnClickListener {
|
||||||
|
val dialogClickListener = DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
accountDeleteCallback.invoke(account)
|
||||||
|
dialog?.dismissSafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
dialog?.dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
AlertDialog.Builder(context).setTitle(R.string.delete).setMessage(
|
||||||
|
context.getString(R.string.delete_message).format(
|
||||||
|
currentEditAccount.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||||
|
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||||
|
.show().setDefaultFocus()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.cancelBtt.setOnClickListener {
|
||||||
|
dialog?.dismissSafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the profile picture and its interactions
|
||||||
|
binding.accountImage.setImage(account.image)
|
||||||
|
binding.accountImage.setOnClickListener {
|
||||||
|
// Roll the image forwards once
|
||||||
|
currentEditAccount =
|
||||||
|
currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size)
|
||||||
|
binding.accountImage.setImage(currentEditAccount.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle applying changes
|
||||||
|
binding.applyBtt.setOnClickListener {
|
||||||
|
if (currentEditAccount.lockPin != null) {
|
||||||
|
// Ask for the current PIN
|
||||||
|
showPinInputDialog(context, currentEditAccount.lockPin, false) { pin ->
|
||||||
|
if (pin == null) return@showPinInputDialog
|
||||||
|
// PIN is correct, proceed to update the account
|
||||||
|
accountEditCallback.invoke(currentEditAccount)
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No lock PIN set, proceed to update the account
|
||||||
|
accountEditCallback.invoke(currentEditAccount)
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle setting or changing the PIN
|
||||||
|
if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) {
|
||||||
|
binding.lockProfileCheckbox.isVisible = false
|
||||||
|
if (currentEditAccount.lockPin != null) {
|
||||||
|
currentEditAccount = currentEditAccount.copy(lockPin = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var canSetPin = true
|
||||||
|
|
||||||
|
binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null
|
||||||
|
|
||||||
|
binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (isChecked) {
|
||||||
|
if (canSetPin) {
|
||||||
|
showPinInputDialog(context, null, true) { pin ->
|
||||||
|
if (pin == null) {
|
||||||
|
binding.lockProfileCheckbox.isChecked = false
|
||||||
|
return@showPinInputDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEditAccount = currentEditAccount.copy(lockPin = pin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentEditAccount.lockPin != null) {
|
||||||
|
// Ask for the current PIN
|
||||||
|
showPinInputDialog(context, currentEditAccount.lockPin, true) { pin ->
|
||||||
|
if (pin == null || pin != currentEditAccount.lockPin) {
|
||||||
|
canSetPin = false
|
||||||
|
binding.lockProfileCheckbox.isChecked = true
|
||||||
|
} else {
|
||||||
|
currentEditAccount = currentEditAccount.copy(lockPin = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canSetPin = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showPinInputDialog(
|
||||||
|
context: Context,
|
||||||
|
currentPin: String?,
|
||||||
|
editAccount: Boolean,
|
||||||
|
forStartup: Boolean = false,
|
||||||
|
errorText: String? = null,
|
||||||
|
callback: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
fun TextView.visibleWithText(@StringRes textRes: Int) {
|
||||||
|
isVisible = true
|
||||||
|
setText(textRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TextView.visibleWithText(text: String?) {
|
||||||
|
isVisible = true
|
||||||
|
setText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context))
|
||||||
|
|
||||||
|
val isPinSet = currentPin != null
|
||||||
|
val isNewPin = editAccount && !isPinSet
|
||||||
|
val isEditPin = editAccount && isPinSet
|
||||||
|
|
||||||
|
val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin
|
||||||
|
|
||||||
|
var isPinValid = false
|
||||||
|
|
||||||
|
val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
|
||||||
|
.setView(binding.root)
|
||||||
|
.setTitle(titleRes)
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ ->
|
||||||
|
callback.invoke(null)
|
||||||
|
}
|
||||||
|
.setOnCancelListener {
|
||||||
|
callback.invoke(null)
|
||||||
|
}
|
||||||
|
.setOnDismissListener {
|
||||||
|
if (!isPinValid) {
|
||||||
|
callback.invoke(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forStartup) {
|
||||||
|
val currentAccount = DataStoreHelper.accounts.firstOrNull {
|
||||||
|
it.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name))
|
||||||
|
builder.setOnDismissListener {
|
||||||
|
if (!isPinValid) {
|
||||||
|
context.getActivity()?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// So that if they don't know the PIN for the current account,
|
||||||
|
// they don't get completely locked out
|
||||||
|
builder.setNeutralButton(R.string.use_default_account) { _, _ ->
|
||||||
|
val activity = context.getActivity()
|
||||||
|
if (activity is AccountSelectActivity) {
|
||||||
|
isPinValid = true
|
||||||
|
activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNewPin) {
|
||||||
|
if (errorText != null) binding.pinEditTextError.visibleWithText(errorText)
|
||||||
|
builder.setPositiveButton(R.string.setup_done) { _, _ ->
|
||||||
|
if (!isPinValid) {
|
||||||
|
// If the done button is pressed and there is an error,
|
||||||
|
// ask again, and mention the error that caused this.
|
||||||
|
showPinInputDialog(
|
||||||
|
context = binding.root.context,
|
||||||
|
currentPin = null,
|
||||||
|
editAccount = true,
|
||||||
|
errorText = binding.pinEditTextError.text.toString(),
|
||||||
|
callback = callback
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val enteredPin = binding.pinEditText.text.toString()
|
||||||
|
callback.invoke(enteredPin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = builder.create()
|
||||||
|
|
||||||
|
binding.pinEditText.doOnTextChanged { text, _, _, _ ->
|
||||||
|
val enteredPin = text.toString()
|
||||||
|
val isEnteredPinValid = enteredPin.length == 4
|
||||||
|
|
||||||
|
if (isEnteredPinValid) {
|
||||||
|
if (isPinSet) {
|
||||||
|
if (enteredPin != currentPin) {
|
||||||
|
binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect)
|
||||||
|
binding.pinEditText.text = null
|
||||||
|
isPinValid = false
|
||||||
|
} else {
|
||||||
|
binding.pinEditTextError.isVisible = false
|
||||||
|
isPinValid = true
|
||||||
|
|
||||||
|
callback.invoke(enteredPin)
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.pinEditTextError.isVisible = false
|
||||||
|
isPinValid = true
|
||||||
|
}
|
||||||
|
} else if (isNewPin) {
|
||||||
|
binding.pinEditTextError.visibleWithText(R.string.pin_error_length)
|
||||||
|
isPinValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect IME_ACTION_DONE
|
||||||
|
binding.pinEditText.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) {
|
||||||
|
val enteredPin = binding.pinEditText.text.toString()
|
||||||
|
callback.invoke(enteredPin)
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't want to accidentally have the dialog dismiss when clicking outside of it.
|
||||||
|
// That is what the cancel button is for.
|
||||||
|
dialog.setCanceledOnTouchOutside(false)
|
||||||
|
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
|
// Auto focus on PIN input and show keyboard
|
||||||
|
binding.pinEditText.requestFocus()
|
||||||
|
binding.pinEditText.postDelayed({
|
||||||
|
showInputMethod(binding.pinEditText)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Activity?.showAccountSelectLinear() {
|
||||||
|
val activity = this as? MainActivity ?: return
|
||||||
|
val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java]
|
||||||
|
|
||||||
|
val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate(
|
||||||
|
LayoutInflater.from(activity)
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = BottomSheetDialog(activity)
|
||||||
|
builder.setContentView(binding.root)
|
||||||
|
builder.show()
|
||||||
|
|
||||||
|
binding.manageAccountsButton.setOnClickListener {
|
||||||
|
val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java)
|
||||||
|
accountSelectIntent.putExtra("isEditingFromMainActivity", true)
|
||||||
|
activity.startActivity(accountSelectIntent)
|
||||||
|
builder.dismissSafe()
|
||||||
|
}
|
||||||
|
|
||||||
|
val recyclerView: RecyclerView = binding.accountRecyclerView
|
||||||
|
|
||||||
|
val itemSize = recyclerView.resources.getDimensionPixelSize(
|
||||||
|
R.dimen.account_select_linear_item_size
|
||||||
|
)
|
||||||
|
|
||||||
|
recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize))
|
||||||
|
|
||||||
|
recyclerView.setLinearListLayout(isHorizontal = true)
|
||||||
|
|
||||||
|
val currentAccount = DataStoreHelper.accounts.firstOrNull {
|
||||||
|
it.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
} ?: getDefaultAccount(activity)
|
||||||
|
|
||||||
|
// We want to make sure the accounts are up-to-date
|
||||||
|
viewModel.handleAccountSelect(
|
||||||
|
currentAccount,
|
||||||
|
activity,
|
||||||
|
reloadForActivity = true
|
||||||
|
)
|
||||||
|
|
||||||
|
activity.observe(viewModel.accounts) { liveAccounts ->
|
||||||
|
recyclerView.adapter = AccountAdapter(
|
||||||
|
liveAccounts,
|
||||||
|
accountSelectCallback = { account ->
|
||||||
|
viewModel.handleAccountSelect(account, activity)
|
||||||
|
builder.dismissSafe()
|
||||||
|
},
|
||||||
|
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||||
|
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
|
||||||
|
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
|
||||||
|
)
|
||||||
|
|
||||||
|
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
|
||||||
|
// Scroll to current account (which is focused by default)
|
||||||
|
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.loadThemes
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
|
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||||
|
import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT
|
||||||
|
import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
|
||||||
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
|
||||||
|
class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
|
||||||
|
|
||||||
|
lateinit var viewModel: AccountViewModel
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
loadThemes(this)
|
||||||
|
|
||||||
|
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
|
||||||
|
|
||||||
|
// Are we editing and coming from MainActivity?
|
||||||
|
val isEditingFromMainActivity = intent.getBooleanExtra(
|
||||||
|
"isEditingFromMainActivity",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
|
||||||
|
) || accounts.count() <= 1
|
||||||
|
|
||||||
|
viewModel = ViewModelProvider(this)[AccountViewModel::class.java]
|
||||||
|
|
||||||
|
fun askBiometricAuth() {
|
||||||
|
|
||||||
|
if (isLayout(PHONE) && isAuthEnabled(this)) {
|
||||||
|
if (deviceHasPasswordPinLock(this)) {
|
||||||
|
startBiometricAuthentication(
|
||||||
|
this,
|
||||||
|
R.string.biometric_authentication_title,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
promptInfo?.let { prompt ->
|
||||||
|
biometricPrompt?.authenticate(prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(viewModel.isAllowedLogin) { isAllowedLogin ->
|
||||||
|
if (isAllowedLogin) {
|
||||||
|
// We are allowed to continue to MainActivity
|
||||||
|
navigateToMainActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show account selection if there is only
|
||||||
|
// one account that exists
|
||||||
|
if (!isEditingFromMainActivity && skipStartup) {
|
||||||
|
val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex }
|
||||||
|
if (currentAccount?.lockPin != null) {
|
||||||
|
CommonActivity.init(this)
|
||||||
|
viewModel.handleAccountSelect(currentAccount, this, true)
|
||||||
|
} else {
|
||||||
|
if (accounts.count() > 1) {
|
||||||
|
showToast(this, getString(
|
||||||
|
R.string.logged_account,
|
||||||
|
currentAccount?.name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToMainActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
CommonActivity.init(this)
|
||||||
|
|
||||||
|
val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
|
||||||
|
|
||||||
|
observe(viewModel.accounts) { liveAccounts ->
|
||||||
|
val adapter = AccountAdapter(
|
||||||
|
liveAccounts,
|
||||||
|
// Handle the selected account
|
||||||
|
accountSelectCallback = {
|
||||||
|
viewModel.handleAccountSelect(it, this)
|
||||||
|
},
|
||||||
|
accountCreateCallback = { viewModel.handleAccountUpdate(it, this) },
|
||||||
|
accountEditCallback = {
|
||||||
|
viewModel.handleAccountUpdate(it, this)
|
||||||
|
|
||||||
|
// We came from MainActivity, return there
|
||||||
|
// and switch to the edited account
|
||||||
|
if (isEditingFromMainActivity) {
|
||||||
|
setAccount(it)
|
||||||
|
navigateToMainActivity()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
accountDeleteCallback = { viewModel.handleAccountDelete(it,this) }
|
||||||
|
)
|
||||||
|
|
||||||
|
recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
if (isLayout(TV or EMULATOR)) {
|
||||||
|
binding.editAccountButton.setBackgroundResource(
|
||||||
|
R.drawable.player_button_tv_attr_no_bg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
|
||||||
|
// Scroll to current account (which is focused by default)
|
||||||
|
val layoutManager = recyclerView.layoutManager as GridLayoutManager
|
||||||
|
layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(viewModel.isEditing) { isEditing ->
|
||||||
|
if (isEditing) {
|
||||||
|
binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24)
|
||||||
|
binding.title.setText(R.string.manage_accounts)
|
||||||
|
adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT
|
||||||
|
} else {
|
||||||
|
binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24)
|
||||||
|
binding.title.setText(R.string.select_an_account)
|
||||||
|
adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditingFromMainActivity) {
|
||||||
|
viewModel.setIsEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.editAccountButton.setOnClickListener {
|
||||||
|
// We came from MainActivity, return there
|
||||||
|
// and resume its state
|
||||||
|
if (isEditingFromMainActivity) {
|
||||||
|
navigateToMainActivity()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.toggleIsEditing()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLayout(TV or EMULATOR)) {
|
||||||
|
recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) {
|
||||||
|
liveAccounts.count() + 1
|
||||||
|
} else 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
askBiometricAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToMainActivity() {
|
||||||
|
val mainIntent = Intent(this, MainActivity::class.java)
|
||||||
|
startActivity(mainIntent)
|
||||||
|
finish() // Finish the account selection activity
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationSuccess() {
|
||||||
|
Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError() {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() {
|
||||||
|
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
val layoutParams = view.layoutParams as RecyclerView.LayoutParams
|
||||||
|
layoutParams.width = size
|
||||||
|
layoutParams.height = size
|
||||||
|
view.layoutParams = layoutParams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.account
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||||
|
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
|
||||||
|
|
||||||
|
class AccountViewModel : ViewModel() {
|
||||||
|
private fun getAllAccounts(): List<DataStoreHelper.Account> {
|
||||||
|
return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _accounts: MutableLiveData<List<DataStoreHelper.Account>> = MutableLiveData(getAllAccounts())
|
||||||
|
val accounts: LiveData<List<DataStoreHelper.Account>> = _accounts
|
||||||
|
|
||||||
|
private val _isEditing = MutableLiveData(false)
|
||||||
|
val isEditing: LiveData<Boolean> = _isEditing
|
||||||
|
|
||||||
|
private val _isAllowedLogin = MutableLiveData(false)
|
||||||
|
val isAllowedLogin: LiveData<Boolean> = _isAllowedLogin
|
||||||
|
|
||||||
|
private val _selectedKeyIndex = MutableLiveData(
|
||||||
|
getAllAccounts().indexOfFirst {
|
||||||
|
it.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val selectedKeyIndex: LiveData<Int> = _selectedKeyIndex
|
||||||
|
|
||||||
|
fun setIsEditing(value: Boolean) {
|
||||||
|
_isEditing.postValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleIsEditing() {
|
||||||
|
_isEditing.postValue(!(_isEditing.value ?: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleAccountUpdate(
|
||||||
|
account: DataStoreHelper.Account,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
val currentAccounts = getAccounts(context).toMutableList()
|
||||||
|
|
||||||
|
val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex }
|
||||||
|
|
||||||
|
if (overrideIndex != -1) {
|
||||||
|
currentAccounts[overrideIndex] = account
|
||||||
|
} else currentAccounts.add(account)
|
||||||
|
|
||||||
|
val currentHomePage = DataStoreHelper.currentHomePage
|
||||||
|
|
||||||
|
setAccount(account)
|
||||||
|
|
||||||
|
DataStoreHelper.currentHomePage = currentHomePage
|
||||||
|
DataStoreHelper.accounts = currentAccounts.toTypedArray()
|
||||||
|
|
||||||
|
_accounts.postValue(getAccounts(context))
|
||||||
|
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleAccountDelete(
|
||||||
|
account: DataStoreHelper.Account,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
removeKeys(account.keyIndex.toString())
|
||||||
|
|
||||||
|
val currentAccounts = getAccounts(context).toMutableList()
|
||||||
|
|
||||||
|
currentAccounts.removeIf { it.keyIndex == account.keyIndex }
|
||||||
|
|
||||||
|
DataStoreHelper.accounts = currentAccounts.toTypedArray()
|
||||||
|
|
||||||
|
if (account.keyIndex == DataStoreHelper.selectedKeyIndex) {
|
||||||
|
setAccount(getDefaultAccount(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
_accounts.postValue(getAccounts(context))
|
||||||
|
_selectedKeyIndex.postValue(getAllAccounts().indexOfFirst {
|
||||||
|
it.keyIndex == DataStoreHelper.selectedKeyIndex
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleAccountSelect(
|
||||||
|
account: DataStoreHelper.Account,
|
||||||
|
context: Context,
|
||||||
|
forStartup: Boolean = false,
|
||||||
|
reloadForActivity: Boolean = false
|
||||||
|
) {
|
||||||
|
if (reloadForActivity) {
|
||||||
|
_accounts.postValue(getAccounts(context))
|
||||||
|
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the selected account has a lock PIN set
|
||||||
|
if (account.lockPin != null) {
|
||||||
|
// The selected account has a PIN set, prompt the user to enter the PIN
|
||||||
|
showPinInputDialog(
|
||||||
|
context,
|
||||||
|
account.lockPin,
|
||||||
|
false,
|
||||||
|
forStartup
|
||||||
|
) { pin ->
|
||||||
|
if (pin == null) return@showPinInputDialog
|
||||||
|
// Pin is correct, proceed
|
||||||
|
_isAllowedLogin.postValue(true)
|
||||||
|
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
|
||||||
|
setAccount(account)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No PIN set for the selected account, proceed
|
||||||
|
_isAllowedLogin.postValue(true)
|
||||||
|
_selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
|
||||||
|
setAccount(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.CheckBox
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
|
|
||||||
|
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
||||||
|
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
||||||
|
const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
|
||||||
|
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
|
||||||
|
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
||||||
|
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
||||||
|
|
||||||
|
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
|
||||||
|
const val DOWNLOAD_ACTION_LOAD_RESULT = 1
|
||||||
|
|
||||||
|
sealed class VisualDownloadCached {
|
||||||
|
abstract val currentBytes: Long
|
||||||
|
abstract val totalBytes: Long
|
||||||
|
abstract val data: VideoDownloadHelper.DownloadCached
|
||||||
|
abstract var isSelected: Boolean
|
||||||
|
|
||||||
|
data class Child(
|
||||||
|
override val currentBytes: Long,
|
||||||
|
override val totalBytes: Long,
|
||||||
|
override val data: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
|
override var isSelected: Boolean,
|
||||||
|
) : VisualDownloadCached()
|
||||||
|
|
||||||
|
data class Header(
|
||||||
|
override val currentBytes: Long,
|
||||||
|
override val totalBytes: Long,
|
||||||
|
override val data: VideoDownloadHelper.DownloadHeaderCached,
|
||||||
|
override var isSelected: Boolean,
|
||||||
|
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
||||||
|
val currentOngoingDownloads: Int,
|
||||||
|
val totalDownloads: Int,
|
||||||
|
) : VisualDownloadCached()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DownloadClickEvent(
|
||||||
|
val action: Int,
|
||||||
|
val data: VideoDownloadHelper.DownloadEpisodeCached
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DownloadHeaderClickEvent(
|
||||||
|
val action: Int,
|
||||||
|
val data: VideoDownloadHelper.DownloadHeaderCached
|
||||||
|
)
|
||||||
|
|
||||||
|
class DownloadAdapter(
|
||||||
|
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
|
||||||
|
private val onItemClickEvent: (DownloadClickEvent) -> Unit,
|
||||||
|
private val onItemSelectionChanged: (Int, Boolean) -> Unit,
|
||||||
|
) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
|
||||||
|
|
||||||
|
private var isMultiDeleteState: Boolean = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VIEW_TYPE_HEADER = 0
|
||||||
|
private const val VIEW_TYPE_CHILD = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DownloadViewHolder(
|
||||||
|
private val binding: ViewBinding
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(card: VisualDownloadCached?) {
|
||||||
|
when (binding) {
|
||||||
|
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
|
||||||
|
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindHeader(card: VisualDownloadCached.Header?) {
|
||||||
|
if (binding !is DownloadHeaderEpisodeBinding || card == null) return
|
||||||
|
|
||||||
|
val data = card.data
|
||||||
|
binding.apply {
|
||||||
|
episodeHolder.apply {
|
||||||
|
if (isMultiDeleteState) {
|
||||||
|
setOnClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnLongClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadHeaderPoster.apply {
|
||||||
|
setImage(data.poster)
|
||||||
|
if (isMultiDeleteState) {
|
||||||
|
setOnClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setOnClickListener {
|
||||||
|
onHeaderClickEvent.invoke(
|
||||||
|
DownloadHeaderClickEvent(
|
||||||
|
DOWNLOAD_ACTION_LOAD_RESULT,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnLongClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadHeaderTitle.text = data.name
|
||||||
|
val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
|
||||||
|
|
||||||
|
if (card.child != null) {
|
||||||
|
handleChildDownload(card, formattedSize)
|
||||||
|
} else handleParentDownload(card, formattedSize)
|
||||||
|
|
||||||
|
if (isMultiDeleteState) {
|
||||||
|
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||||
|
}
|
||||||
|
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||||
|
|
||||||
|
deleteCheckbox.apply {
|
||||||
|
isVisible = isMultiDeleteState
|
||||||
|
isChecked = card.isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DownloadHeaderEpisodeBinding.handleChildDownload(
|
||||||
|
card: VisualDownloadCached.Header,
|
||||||
|
formattedSize: String
|
||||||
|
) {
|
||||||
|
card.child ?: return
|
||||||
|
downloadHeaderGotoChild.isVisible = false
|
||||||
|
|
||||||
|
val posDur = getViewPos(card.data.id)
|
||||||
|
downloadHeaderEpisodeProgress.apply {
|
||||||
|
isVisible = posDur != null
|
||||||
|
posDur?.let {
|
||||||
|
val visualPos = it.fixVisual()
|
||||||
|
max = (visualPos.duration / 1000).toInt()
|
||||||
|
progress = (visualPos.position / 1000).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
|
||||||
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
|
// We do this here instead if we are finished downloading
|
||||||
|
// so that we can use the value from the view model
|
||||||
|
// rather than extra unneeded disk operations and to prevent a
|
||||||
|
// delay in updating download icon state.
|
||||||
|
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||||
|
downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
|
||||||
|
// We will let the view model handle this
|
||||||
|
downloadButton.doSetProgress = false
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
downloadButton.getDrawableFromStatus(status)
|
||||||
|
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||||
|
downloadHeaderInfo.text = formattedSize
|
||||||
|
} else {
|
||||||
|
// We need to make sure we restore the correct progress
|
||||||
|
// when we refresh data in the adapter.
|
||||||
|
downloadButton.resetView()
|
||||||
|
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||||
|
ContextCompat.getDrawable(downloadButton.context, it)
|
||||||
|
}
|
||||||
|
downloadButton.statusView.setImageDrawable(drawable)
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
downloadButton.context,
|
||||||
|
downloadButton.progressDrawable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
|
||||||
|
downloadButton.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
|
if (!isMultiDeleteState) {
|
||||||
|
episodeHolder.setOnClickListener {
|
||||||
|
onItemClickEvent.invoke(
|
||||||
|
DownloadClickEvent(
|
||||||
|
DOWNLOAD_ACTION_PLAY_FILE,
|
||||||
|
card.child
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DownloadHeaderEpisodeBinding.handleParentDownload(
|
||||||
|
card: VisualDownloadCached.Header,
|
||||||
|
formattedSize: String
|
||||||
|
) {
|
||||||
|
downloadButton.isVisible = false
|
||||||
|
downloadHeaderEpisodeProgress.isVisible = false
|
||||||
|
downloadHeaderGotoChild.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadHeaderInfo.text =
|
||||||
|
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
|
||||||
|
card.totalDownloads,
|
||||||
|
downloadHeaderInfo.context.resources.getQuantityString(
|
||||||
|
R.plurals.episodes,
|
||||||
|
card.totalDownloads
|
||||||
|
),
|
||||||
|
formattedSize
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
downloadHeaderInfo.text = null
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMultiDeleteState) {
|
||||||
|
episodeHolder.setOnClickListener {
|
||||||
|
onHeaderClickEvent.invoke(
|
||||||
|
DownloadHeaderClickEvent(
|
||||||
|
DOWNLOAD_ACTION_GO_TO_CHILD,
|
||||||
|
card.data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindChild(card: VisualDownloadCached.Child?) {
|
||||||
|
if (binding !is DownloadChildEpisodeBinding || card == null) return
|
||||||
|
|
||||||
|
val data = card.data
|
||||||
|
binding.apply {
|
||||||
|
val posDur = getViewPos(data.id)
|
||||||
|
downloadChildEpisodeProgress.apply {
|
||||||
|
isVisible = posDur != null
|
||||||
|
posDur?.let {
|
||||||
|
val visualPos = it.fixVisual()
|
||||||
|
max = (visualPos.duration / 1000).toInt()
|
||||||
|
progress = (visualPos.position / 1000).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
|
||||||
|
if (status == DownloadStatusTell.IsDone) {
|
||||||
|
// We do this here instead if we are finished downloading
|
||||||
|
// so that we can use the value from the view model
|
||||||
|
// rather than extra unneeded disk operations and to prevent a
|
||||||
|
// delay in updating download icon state.
|
||||||
|
downloadButton.setProgress(card.currentBytes, card.totalBytes)
|
||||||
|
downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
|
||||||
|
// We will let the view model handle this
|
||||||
|
downloadButton.doSetProgress = false
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
downloadButton.getDrawableFromStatus(status)
|
||||||
|
?.let { ContextCompat.getDrawable(downloadButton.context, it) }
|
||||||
|
downloadChildEpisodeTextExtra.text =
|
||||||
|
formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
|
||||||
|
} else {
|
||||||
|
// We need to make sure we restore the correct progress
|
||||||
|
// when we refresh data in the adapter.
|
||||||
|
downloadButton.resetView()
|
||||||
|
val drawable = downloadButton.getDrawableFromStatus(status)?.let {
|
||||||
|
ContextCompat.getDrawable(downloadButton.context, it)
|
||||||
|
}
|
||||||
|
downloadButton.statusView.setImageDrawable(drawable)
|
||||||
|
downloadButton.progressBar.progressDrawable =
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
downloadButton.context,
|
||||||
|
downloadButton.progressDrawable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadButton.setDefaultClickListener(
|
||||||
|
data,
|
||||||
|
downloadChildEpisodeTextExtra,
|
||||||
|
onItemClickEvent
|
||||||
|
)
|
||||||
|
downloadButton.isVisible = !isMultiDeleteState
|
||||||
|
|
||||||
|
downloadChildEpisodeText.apply {
|
||||||
|
text = context.getNameFull(data.name, data.episode, data.season)
|
||||||
|
isSelected = true // Needed for text repeating
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadChildEpisodeHolder.setOnClickListener {
|
||||||
|
onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadChildEpisodeHolder.apply {
|
||||||
|
when {
|
||||||
|
isMultiDeleteState -> {
|
||||||
|
setOnClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
setOnClickListener {
|
||||||
|
onItemClickEvent.invoke(
|
||||||
|
DownloadClickEvent(
|
||||||
|
DOWNLOAD_ACTION_PLAY_FILE,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnLongClickListener {
|
||||||
|
toggleIsChecked(deleteCheckbox, data.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMultiDeleteState) {
|
||||||
|
deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onItemSelectionChanged.invoke(data.id, isChecked)
|
||||||
|
}
|
||||||
|
} else deleteCheckbox.setOnCheckedChangeListener(null)
|
||||||
|
|
||||||
|
deleteCheckbox.apply {
|
||||||
|
isVisible = isMultiDeleteState
|
||||||
|
isChecked = card.isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
val binding = when (viewType) {
|
||||||
|
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
|
||||||
|
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
|
||||||
|
else -> throw IllegalArgumentException("Invalid view type")
|
||||||
|
}
|
||||||
|
return DownloadViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
return when (getItem(position)) {
|
||||||
|
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
|
||||||
|
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
|
||||||
|
else -> throw IllegalArgumentException("Invalid data type at position $position")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIsMultiDeleteState(value: Boolean) {
|
||||||
|
if (isMultiDeleteState == value) return
|
||||||
|
isMultiDeleteState = value
|
||||||
|
notifyItemRangeChanged(0, itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyAllSelected() {
|
||||||
|
currentList.indices.forEach { index ->
|
||||||
|
if (!currentList[index].isSelected) {
|
||||||
|
notifyItemChanged(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifySelectionStates() {
|
||||||
|
currentList.indices.forEach { index ->
|
||||||
|
if (currentList[index].isSelected) {
|
||||||
|
notifyItemChanged(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {
|
||||||
|
val isChecked = !checkbox.isChecked
|
||||||
|
checkbox.isChecked = isChecked
|
||||||
|
onItemSelectionChanged.invoke(itemId, isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiffCallback : DiffUtil.ItemCallback<VisualDownloadCached>() {
|
||||||
|
override fun areItemsTheSame(
|
||||||
|
oldItem: VisualDownloadCached,
|
||||||
|
newItem: VisualDownloadCached
|
||||||
|
): Boolean {
|
||||||
|
return oldItem.data.id == newItem.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(
|
||||||
|
oldItem: VisualDownloadCached,
|
||||||
|
newItem: VisualDownloadCached
|
||||||
|
): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,30 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.widget.Toast
|
import android.net.Uri
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
|
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
|
||||||
|
import com.lagradost.cloudstream3.ui.player.ExtractorUri
|
||||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
|
||||||
object DownloadButtonSetup {
|
object DownloadButtonSetup {
|
||||||
fun handleDownloadClick(activity: Activity?, click: DownloadClickEvent) {
|
fun handleDownloadClick(click: DownloadClickEvent) {
|
||||||
val id = click.data.id
|
val id = click.data.id
|
||||||
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
|
|
||||||
when (click.action) {
|
when (click.action) {
|
||||||
DOWNLOAD_ACTION_DELETE_FILE -> {
|
DOWNLOAD_ACTION_DELETE_FILE -> {
|
||||||
activity?.let { ctx ->
|
activity?.let { ctx ->
|
||||||
|
|
@ -30,9 +33,15 @@ object DownloadButtonSetup {
|
||||||
DialogInterface.OnClickListener { _, which ->
|
DialogInterface.OnClickListener { _, which ->
|
||||||
when (which) {
|
when (which) {
|
||||||
DialogInterface.BUTTON_POSITIVE -> {
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id)
|
VideoDownloadManager.deleteFilesAndUpdateSettings(
|
||||||
|
ctx,
|
||||||
|
setOf(id),
|
||||||
|
MainScope()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogInterface.BUTTON_NEGATIVE -> {
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
// Do nothing on cancel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,11 +66,13 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
|
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
|
||||||
VideoDownloadManager.downloadEvent.invoke(
|
VideoDownloadManager.downloadEvent.invoke(
|
||||||
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
|
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
|
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
|
||||||
activity?.let { ctx ->
|
activity?.let { ctx ->
|
||||||
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
|
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
|
||||||
|
|
@ -80,6 +91,7 @@ object DownloadButtonSetup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val length =
|
val length =
|
||||||
|
|
@ -89,64 +101,80 @@ object DownloadButtonSetup {
|
||||||
)?.fileLength
|
)?.fileLength
|
||||||
?: 0
|
?: 0
|
||||||
if (length > 0) {
|
if (length > 0) {
|
||||||
showToast(act, R.string.delete, Toast.LENGTH_LONG)
|
showSnackbar(
|
||||||
} else {
|
act,
|
||||||
showToast(act, R.string.download, Toast.LENGTH_LONG)
|
R.string.offline_file,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DOWNLOAD_ACTION_PLAY_FILE -> {
|
DOWNLOAD_ACTION_PLAY_FILE -> {
|
||||||
activity?.let { act ->
|
activity?.let { act ->
|
||||||
val info =
|
|
||||||
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
|
|
||||||
act,
|
|
||||||
click.data.id
|
|
||||||
) ?: return
|
|
||||||
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
|
|
||||||
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
|
||||||
click.data.id.toString()
|
|
||||||
) ?: return
|
|
||||||
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
click.data.parentId.toString()
|
click.data.parentId.toString()
|
||||||
) ?: return
|
) ?: return
|
||||||
|
|
||||||
act.navigate(
|
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
?.mapNotNull {
|
||||||
DownloadFileGenerator(
|
getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
|
||||||
listOf(
|
}
|
||||||
ExtractorUri(
|
?.filter { it.parentId == click.data.parentId }
|
||||||
uri = info.path,
|
|
||||||
|
|
||||||
id = click.data.id,
|
val currentSeason = click.data.season ?: 0
|
||||||
parentId = click.data.parentId,
|
val currentEpisode = click.data.episode
|
||||||
name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
|
|
||||||
season = click.data.season,
|
|
||||||
episode = click.data.episode,
|
|
||||||
headerName = parent.name,
|
|
||||||
tvType = parent.type,
|
|
||||||
|
|
||||||
basePath = keyInfo.basePath,
|
val items = mutableListOf<ExtractorUri>()
|
||||||
displayName = keyInfo.displayName,
|
|
||||||
relativePath = keyInfo.relativePath,
|
// Make sure we only get this episode and episodes after it,
|
||||||
)
|
// and that we can go to the next season if we need to.
|
||||||
)
|
val allRelevantEpisodes = episodes
|
||||||
|
?.sortedWith(
|
||||||
|
compareByDescending<VideoDownloadHelper.DownloadEpisodeCached> { it.id == click.data.id }
|
||||||
|
.thenBy { it.season ?: 0 }
|
||||||
|
.thenBy { it.episode }
|
||||||
|
)
|
||||||
|
?.filter {
|
||||||
|
if (it.season == null) return@filter true
|
||||||
|
val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id)
|
||||||
|
val isInFutureSeasons = it.season > currentSeason
|
||||||
|
|
||||||
|
isCurrentOrLaterInSeason || isInFutureSeasons
|
||||||
|
}
|
||||||
|
|
||||||
|
allRelevantEpisodes?.forEach {
|
||||||
|
val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
|
||||||
|
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
||||||
|
it.id.toString()
|
||||||
|
) ?: return@forEach
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
ExtractorUri(
|
||||||
|
// We just use a temporary placeholder for the URI,
|
||||||
|
// it will be updated in generateLinks().
|
||||||
|
// We just do this for performance since getting
|
||||||
|
// all paths at once can be quite expensive.
|
||||||
|
uri = Uri.EMPTY,
|
||||||
|
id = it.id,
|
||||||
|
parentId = it.parentId,
|
||||||
|
name = act.getString(R.string.downloaded_file),
|
||||||
|
season = it.season,
|
||||||
|
episode = it.episode,
|
||||||
|
headerName = parent.name,
|
||||||
|
tvType = parent.type,
|
||||||
|
basePath = keyInfo.basePath,
|
||||||
|
displayName = keyInfo.displayName,
|
||||||
|
relativePath = keyInfo.relativePath,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
//R.id.global_to_navigation_player, PlayerFragment.newInstance(
|
}
|
||||||
// UriData(
|
|
||||||
// info.path.toString(),
|
act.navigate(
|
||||||
// keyInfo.basePath,
|
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||||
// keyInfo.relativePath,
|
DownloadFileGenerator(items)
|
||||||
// keyInfo.displayName,
|
)
|
||||||
// click.data.parentId,
|
|
||||||
// click.data.id,
|
|
||||||
// headerName ?: "null",
|
|
||||||
// if (click.data.episode <= 0) null else click.data.episode,
|
|
||||||
// click.data.season
|
|
||||||
// ),
|
|
||||||
// getViewPos(click.data.id)?.position ?: 0
|
|
||||||
//)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
|
||||||
|
|
||||||
interface DownloadButtonViewHolder {
|
|
||||||
var downloadButton : EasyDownloadButton
|
|
||||||
fun reattachDownloadButton()
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.widget.ContentLoadingProgressBar
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
|
||||||
import kotlinx.android.synthetic.main.download_child_episode.view.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
|
||||||
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
|
||||||
const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
|
|
||||||
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
|
|
||||||
const val DOWNLOAD_ACTION_DOWNLOAD = 4
|
|
||||||
const val DOWNLOAD_ACTION_LONG_CLICK = 5
|
|
||||||
|
|
||||||
data class VisualDownloadChildCached(
|
|
||||||
val currentBytes: Long,
|
|
||||||
val totalBytes: Long,
|
|
||||||
val data: VideoDownloadHelper.DownloadEpisodeCached,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData)
|
|
||||||
|
|
||||||
class DownloadChildAdapter(
|
|
||||||
var cardList: List<VisualDownloadChildCached>,
|
|
||||||
private val clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
||||||
|
|
||||||
private val mBoundViewHolders: HashSet<DownloadButtonViewHolder> = HashSet()
|
|
||||||
private fun getAllBoundViewHolders(): Set<DownloadButtonViewHolder?>? {
|
|
||||||
return Collections.unmodifiableSet(mBoundViewHolders)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun killAdapter() {
|
|
||||||
getAllBoundViewHolders()?.forEach { view ->
|
|
||||||
view?.downloadButton?.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.downloadButton.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.downloadButton.dispose()
|
|
||||||
mBoundViewHolders.remove(holder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.reattachDownloadButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
return DownloadChildViewHolder(
|
|
||||||
LayoutInflater.from(parent.context).inflate(R.layout.download_child_episode, parent, false),
|
|
||||||
clickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is DownloadChildViewHolder -> {
|
|
||||||
holder.bind(cardList[position])
|
|
||||||
mBoundViewHolders.add(holder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return cardList.size
|
|
||||||
}
|
|
||||||
|
|
||||||
class DownloadChildViewHolder
|
|
||||||
constructor(
|
|
||||||
itemView: View,
|
|
||||||
private val clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder {
|
|
||||||
override var downloadButton = EasyDownloadButton()
|
|
||||||
|
|
||||||
private val title: TextView = itemView.download_child_episode_text
|
|
||||||
private val extraInfo: TextView = itemView.download_child_episode_text_extra
|
|
||||||
private val holder: CardView = itemView.download_child_episode_holder
|
|
||||||
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
|
|
||||||
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
|
|
||||||
private val downloadImage: ImageView = itemView.download_child_episode_download
|
|
||||||
|
|
||||||
private var localCard: VisualDownloadChildCached? = null
|
|
||||||
|
|
||||||
fun bind(card: VisualDownloadChildCached) {
|
|
||||||
localCard = card
|
|
||||||
val d = card.data
|
|
||||||
|
|
||||||
val posDur = getViewPos(d.id)
|
|
||||||
if (posDur != null) {
|
|
||||||
val visualPos = posDur.fixVisual()
|
|
||||||
progressBar.max = (visualPos.duration / 1000).toInt()
|
|
||||||
progressBar.progress = (visualPos.position / 1000).toInt()
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
title.text = title.context.getNameFull(d.name, d.episode, d.season)
|
|
||||||
title.isSelected = true // is needed for text repeating
|
|
||||||
|
|
||||||
downloadButton.setUpButton(
|
|
||||||
card.currentBytes,
|
|
||||||
card.totalBytes,
|
|
||||||
progressBarDownload,
|
|
||||||
downloadImage,
|
|
||||||
extraInfo,
|
|
||||||
card.data,
|
|
||||||
clickCallback
|
|
||||||
)
|
|
||||||
|
|
||||||
holder.setOnClickListener {
|
|
||||||
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reattachDownloadButton() {
|
|
||||||
downloadButton.dispose()
|
|
||||||
val card = localCard
|
|
||||||
if (card != null) {
|
|
||||||
downloadButton.setUpButton(
|
|
||||||
card.currentBytes,
|
|
||||||
card.totalBytes,
|
|
||||||
progressBarDownload,
|
|
||||||
downloadImage,
|
|
||||||
extraInfo,
|
|
||||||
card.data,
|
|
||||||
clickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,35 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
|
||||||
import kotlinx.android.synthetic.main.fragment_child_downloads.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class DownloadChildFragment : Fragment() {
|
class DownloadChildFragment : Fragment() {
|
||||||
|
private lateinit var downloadsViewModel: DownloadViewModel
|
||||||
|
private var binding: FragmentChildDownloadsBinding? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun newInstance(headerName: String, folder: String) : Bundle {
|
fun newInstance(headerName: String, folder: String): Bundle {
|
||||||
return Bundle().apply {
|
return Bundle().apply {
|
||||||
putString("folder", folder)
|
putString("folder", folder)
|
||||||
putString("name", headerName)
|
putString("name", headerName)
|
||||||
|
|
@ -30,77 +38,170 @@ class DownloadChildFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
(download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter()
|
detachBackPressedCallback()
|
||||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
|
binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(
|
||||||
return inflater.inflate(R.layout.fragment_child_downloads, container, false)
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
|
||||||
|
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
|
||||||
|
binding = localBinding
|
||||||
|
return localBinding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateList(folder: String) = main {
|
|
||||||
context?.let { ctx ->
|
|
||||||
val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
|
|
||||||
val eps = withContext(Dispatchers.IO) {
|
|
||||||
data.mapNotNull { key ->
|
|
||||||
context?.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
|
||||||
}.mapNotNull {
|
|
||||||
val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
|
|
||||||
?: return@mapNotNull null
|
|
||||||
VisualDownloadChildCached(info.fileLength, info.totalBytes, it)
|
|
||||||
}
|
|
||||||
}.sortedBy { it.data.episode + (it.data.season?: 0)*100000 }
|
|
||||||
if (eps.isEmpty()) {
|
|
||||||
activity?.onBackPressed()
|
|
||||||
return@main
|
|
||||||
}
|
|
||||||
|
|
||||||
(download_child_list?.adapter as DownloadChildAdapter? ?: return@main).cardList = eps
|
|
||||||
download_child_list?.adapter?.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We never want to retain multi-delete state
|
||||||
|
* when navigating to downloads. Setting this state
|
||||||
|
* immediately can sometimes result in the observer
|
||||||
|
* not being notified in time to update the UI.
|
||||||
|
*
|
||||||
|
* By posting to the main looper, we ensure that this
|
||||||
|
* operation is executed after the view has been fully created
|
||||||
|
* and all initializations are completed, allowing the
|
||||||
|
* observer to properly receive and handle the state change.
|
||||||
|
*/
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We have to make sure selected items are
|
||||||
|
* cleared here as well so we don't run in an
|
||||||
|
* inconsistent state where selected items do
|
||||||
|
* not match the multi delete state we are in.
|
||||||
|
*/
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
|
||||||
val folder = arguments?.getString("folder")
|
val folder = arguments?.getString("folder")
|
||||||
val name = arguments?.getString("name")
|
val name = arguments?.getString("name")
|
||||||
if (folder == null) {
|
if (folder == null) {
|
||||||
activity?.onBackPressed() // TODO FIX
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context?.fixPaddingStatusbar(download_child_root)
|
|
||||||
|
|
||||||
download_child_toolbar.title = name
|
binding?.downloadChildToolbar?.apply {
|
||||||
download_child_toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
title = name
|
||||||
download_child_toolbar.setNavigationOnClickListener {
|
if (isLayout(PHONE or EMULATOR)) {
|
||||||
activity?.onBackPressed()
|
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||||
}
|
setNavigationOnClickListener {
|
||||||
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
|
|
||||||
DownloadChildAdapter(
|
|
||||||
ArrayList(),
|
|
||||||
) { click ->
|
|
||||||
handleDownloadClick(activity, click)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadDeleteEventListener = { id: Int ->
|
|
||||||
val list = (download_child_list?.adapter as DownloadChildAdapter?)?.cardList
|
|
||||||
if (list != null) {
|
|
||||||
if (list.any { it.data.id == id }) {
|
|
||||||
updateList(folder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setAppBarNoScrollFlagsOnTV()
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
|
|
||||||
download_child_list.adapter = adapter
|
observe(downloadsViewModel.childCards) {
|
||||||
download_child_list.layoutManager = GridLayoutManager(context, 1)
|
if (it.isEmpty()) {
|
||||||
|
activity?.onBackPressedDispatcher?.onBackPressed()
|
||||||
|
return@observe
|
||||||
|
}
|
||||||
|
|
||||||
updateList(folder)
|
(binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||||
|
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||||
|
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||||
|
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||||
|
if (!isMultiDeleteState) {
|
||||||
|
detachBackPressedCallback()
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
binding?.downloadChildToolbar?.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.selectedBytes) {
|
||||||
|
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.selectedItemIds) {
|
||||||
|
handleSelectedChange(it)
|
||||||
|
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||||
|
|
||||||
|
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||||
|
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||||
|
|
||||||
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
|
if (allSelected) {
|
||||||
|
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||||
|
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||||
|
}
|
||||||
|
|
||||||
|
val adapter = DownloadAdapter(
|
||||||
|
{},
|
||||||
|
{ click ->
|
||||||
|
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||||
|
context?.let { ctx ->
|
||||||
|
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||||
|
}
|
||||||
|
} else handleDownloadClick(click)
|
||||||
|
},
|
||||||
|
{ itemId, isChecked ->
|
||||||
|
if (isChecked) {
|
||||||
|
downloadsViewModel.addSelected(itemId)
|
||||||
|
} else downloadsViewModel.removeSelected(itemId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding?.downloadChildList?.apply {
|
||||||
|
setHasFixedSize(true)
|
||||||
|
setItemViewCacheSize(20)
|
||||||
|
this.adapter = adapter
|
||||||
|
setLinearListLayout(
|
||||||
|
isHorizontal = false,
|
||||||
|
nextRight = FOCUS_SELF,
|
||||||
|
nextDown = FOCUS_SELF,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context?.let { downloadsViewModel.updateChildList(it, folder) }
|
||||||
|
fixPaddingStatusbar(binding?.downloadChildRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||||
|
if (selected.isNotEmpty()) {
|
||||||
|
binding?.downloadDeleteAppbar?.isVisible = true
|
||||||
|
binding?.downloadChildToolbar?.isVisible = false
|
||||||
|
activity?.attachBackPressedCallback {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnDelete?.setOnClickListener {
|
||||||
|
context?.let { ctx ->
|
||||||
|
downloadsViewModel.handleMultiDelete(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnCancel?.setOnClickListener {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnToggleAll?.setOnClickListener {
|
||||||
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
|
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
|
||||||
|
if (allSelected) {
|
||||||
|
adapter?.notifySelectionStates()
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
} else {
|
||||||
|
adapter?.notifyAllSelected()
|
||||||
|
downloadsViewModel.selectAllItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||||
|
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||||
|
binding?.btnDelete?.text =
|
||||||
|
getString(R.string.delete_format).format(count, formattedSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,53 +1,62 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.isMovieType
|
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
||||||
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
|
||||||
|
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||||
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
import com.lagradost.cloudstream3.ui.player.LinkGenerator
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||||
|
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DataStore
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
|
||||||
import kotlinx.android.synthetic.main.fragment_downloads.*
|
|
||||||
import kotlinx.android.synthetic.main.stream_input.*
|
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
|
||||||
import androidx.core.widget.doOnTextChanged
|
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
|
|
||||||
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
|
||||||
|
|
||||||
class DownloadFragment : Fragment() {
|
class DownloadFragment : Fragment() {
|
||||||
private lateinit var downloadsViewModel: DownloadViewModel
|
private lateinit var downloadsViewModel: DownloadViewModel
|
||||||
|
private var binding: FragmentDownloadsBinding? = null
|
||||||
|
|
||||||
private fun View.setLayoutWidth(weight: Long) {
|
private fun View.setLayoutWidth(weight: Long) {
|
||||||
val param = LinearLayout.LayoutParams(
|
val param = LinearLayout.LayoutParams(
|
||||||
|
|
@ -58,19 +67,9 @@ class DownloadFragment : Fragment() {
|
||||||
this.layoutParams = param
|
this.layoutParams = param
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setList(list: List<VisualDownloadHeaderCached>) {
|
|
||||||
main {
|
|
||||||
(download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list
|
|
||||||
download_list?.adapter?.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
if (downloadDeleteEventListener != null) {
|
detachBackPressedCallback()
|
||||||
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
|
binding = null
|
||||||
downloadDeleteEventListener = null
|
|
||||||
}
|
|
||||||
(download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter()
|
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,181 +77,315 @@ class DownloadFragment : Fragment() {
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View? {
|
): View {
|
||||||
downloadsViewModel =
|
downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
|
||||||
ViewModelProvider(this)[DownloadViewModel::class.java]
|
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
|
||||||
|
binding = localBinding
|
||||||
return inflater.inflate(R.layout.fragment_downloads, container, false)
|
return localBinding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
private var downloadDeleteEventListener: ((Int) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
|
binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
|
binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
|
||||||
|
|
||||||
observe(downloadsViewModel.noDownloadsText) {
|
/**
|
||||||
text_no_downloads.text = it
|
* We never want to retain multi-delete state
|
||||||
|
* when navigating to downloads. Setting this state
|
||||||
|
* immediately can sometimes result in the observer
|
||||||
|
* not being notified in time to update the UI.
|
||||||
|
*
|
||||||
|
* By posting to the main looper, we ensure that this
|
||||||
|
* operation is executed after the view has been fully created
|
||||||
|
* and all initializations are completed, allowing the
|
||||||
|
* observer to properly receive and handle the state change.
|
||||||
|
*/
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We have to make sure selected items are
|
||||||
|
* cleared here as well so we don't run in an
|
||||||
|
* inconsistent state where selected items do
|
||||||
|
* not match the multi delete state we are in.
|
||||||
|
*/
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
|
||||||
observe(downloadsViewModel.headerCards) {
|
observe(downloadsViewModel.headerCards) {
|
||||||
setList(it)
|
(binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
|
||||||
download_loading.isVisible = false
|
binding?.downloadLoading?.isVisible = false
|
||||||
|
binding?.textNoDownloads?.isVisible = it.isEmpty()
|
||||||
}
|
}
|
||||||
observe(downloadsViewModel.availableBytes) {
|
observe(downloadsViewModel.availableBytes) {
|
||||||
download_free_txt?.text =
|
updateStorageInfo(
|
||||||
getString(R.string.storage_size_format).format(
|
view.context,
|
||||||
getString(R.string.free_storage),
|
it,
|
||||||
formatShortFileSize(view.context, it)
|
R.string.free_storage,
|
||||||
)
|
binding?.downloadFreeTxt,
|
||||||
download_free?.setLayoutWidth(it)
|
binding?.downloadFree
|
||||||
|
)
|
||||||
}
|
}
|
||||||
observe(downloadsViewModel.usedBytes) {
|
observe(downloadsViewModel.usedBytes) {
|
||||||
download_used_txt?.text =
|
updateStorageInfo(
|
||||||
getString(R.string.storage_size_format).format(
|
view.context,
|
||||||
getString(R.string.used_storage),
|
it,
|
||||||
formatShortFileSize(view.context, it)
|
R.string.used_storage,
|
||||||
)
|
binding?.downloadUsedTxt,
|
||||||
download_used?.setLayoutWidth(it)
|
binding?.downloadUsed
|
||||||
download_storage_appbar?.isVisible = it > 0
|
|
||||||
}
|
|
||||||
observe(downloadsViewModel.downloadBytes) {
|
|
||||||
download_app_txt?.text =
|
|
||||||
getString(R.string.storage_size_format).format(
|
|
||||||
getString(R.string.app_storage),
|
|
||||||
formatShortFileSize(view.context, it)
|
|
||||||
)
|
|
||||||
download_app?.setLayoutWidth(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
|
|
||||||
DownloadHeaderAdapter(
|
|
||||||
ArrayList(),
|
|
||||||
{ click ->
|
|
||||||
when (click.action) {
|
|
||||||
0 -> {
|
|
||||||
if (click.data.type.isMovieType()) {
|
|
||||||
//wont be called
|
|
||||||
} else {
|
|
||||||
val folder = DataStore.getFolderName(
|
|
||||||
DOWNLOAD_EPISODE_CACHE,
|
|
||||||
click.data.id.toString()
|
|
||||||
)
|
|
||||||
activity?.navigate(
|
|
||||||
R.id.action_navigation_downloads_to_navigation_download_child,
|
|
||||||
DownloadChildFragment.newInstance(click.data.name, folder)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 -> {
|
|
||||||
(activity as AppCompatActivity?)?.loadResult(
|
|
||||||
click.data.url,
|
|
||||||
click.data.apiName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
{ downloadClickEvent ->
|
|
||||||
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
|
|
||||||
handleDownloadClick(activity, downloadClickEvent)
|
|
||||||
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
|
||||||
context?.let { ctx ->
|
|
||||||
downloadsViewModel.updateList(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
downloadDeleteEventListener = { id ->
|
// Prevent race condition and make sure
|
||||||
val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList
|
// we don't display it early
|
||||||
if (list != null) {
|
if (
|
||||||
if (list.any { it.data.id == id }) {
|
downloadsViewModel.isMultiDeleteState.value == null ||
|
||||||
context?.let { ctx ->
|
downloadsViewModel.isMultiDeleteState.value == false
|
||||||
setList(ArrayList())
|
) binding?.downloadStorageAppbar?.isVisible = it > 0
|
||||||
downloadsViewModel.updateList(ctx)
|
}
|
||||||
}
|
observe(downloadsViewModel.downloadBytes) {
|
||||||
|
updateStorageInfo(
|
||||||
|
view.context,
|
||||||
|
it,
|
||||||
|
R.string.app_storage,
|
||||||
|
binding?.downloadAppTxt,
|
||||||
|
binding?.downloadApp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.selectedBytes) {
|
||||||
|
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
|
||||||
|
}
|
||||||
|
observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
|
||||||
|
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||||
|
adapter?.setIsMultiDeleteState(isMultiDeleteState)
|
||||||
|
binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
|
||||||
|
if (!isMultiDeleteState) {
|
||||||
|
detachBackPressedCallback()
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
// Prevent race condition and make sure
|
||||||
|
// we don't display it early
|
||||||
|
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
|
||||||
|
binding?.downloadStorageAppbar?.isVisible = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
observe(downloadsViewModel.selectedItemIds) {
|
||||||
|
handleSelectedChange(it)
|
||||||
|
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
|
||||||
|
|
||||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
binding?.btnDelete?.isVisible = it.isNotEmpty()
|
||||||
|
binding?.selectItemsText?.isVisible = it.isEmpty()
|
||||||
|
|
||||||
download_list?.adapter = adapter
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
download_list?.layoutManager = GridLayoutManager(context, 1)
|
if (allSelected) {
|
||||||
|
binding?.btnToggleAll?.setText(R.string.deselect_all)
|
||||||
|
} else binding?.btnToggleAll?.setText(R.string.select_all)
|
||||||
|
}
|
||||||
|
|
||||||
// Should be visible in emulator layout
|
val adapter = DownloadAdapter(
|
||||||
download_stream_button?.isGone = isTrueTvSettings()
|
{ click -> handleItemClick(click) },
|
||||||
download_stream_button?.setOnClickListener {
|
{ click ->
|
||||||
val dialog =
|
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||||
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
|
context?.let { ctx ->
|
||||||
dialog.setContentView(R.layout.stream_input)
|
downloadsViewModel.handleSingleDelete(ctx, click.data.id)
|
||||||
|
}
|
||||||
dialog.show()
|
} else handleDownloadClick(click)
|
||||||
|
},
|
||||||
// If user has clicked the switch do not interfere
|
{ itemId, isChecked ->
|
||||||
var preventAutoSwitching = false
|
if (isChecked) {
|
||||||
dialog.hls_switch?.setOnClickListener {
|
downloadsViewModel.addSelected(itemId)
|
||||||
preventAutoSwitching = true
|
} else downloadsViewModel.removeSelected(itemId)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
fun activateSwitchOnHls(text: String?) {
|
binding?.downloadList?.apply {
|
||||||
dialog.hls_switch?.isChecked = normalSafeApiCall {
|
setHasFixedSize(true)
|
||||||
URI(text).path?.substringAfterLast(".")?.contains("m3u")
|
setItemViewCacheSize(20)
|
||||||
} == true
|
this.adapter = adapter
|
||||||
|
setLinearListLayout(
|
||||||
|
isHorizontal = false,
|
||||||
|
nextRight = FOCUS_SELF,
|
||||||
|
nextDown = FOCUS_SELF,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.apply {
|
||||||
|
openLocalVideoButton.apply {
|
||||||
|
isGone = isLayout(TV)
|
||||||
|
setOnClickListener { openLocalVideo() }
|
||||||
}
|
}
|
||||||
|
downloadStreamButton.apply {
|
||||||
dialog.stream_referer?.doOnTextChanged { text, _, _, _ ->
|
isGone = isLayout(TV)
|
||||||
if (!preventAutoSwitching)
|
setOnClickListener { showStreamInputDialog(it.context) }
|
||||||
activateSwitchOnHls(text?.toString())
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt(
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
0
|
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||||
)?.text?.toString()?.let { copy ->
|
handleScroll(scrollY - oldScrollY)
|
||||||
val fixedText = copy.trim()
|
|
||||||
dialog.stream_url?.setText(fixedText)
|
|
||||||
activateSwitchOnHls(fixedText)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dialog.apply_btt?.setOnClickListener {
|
context?.let { downloadsViewModel.updateHeaderList(it) }
|
||||||
val url = dialog.stream_url.text?.toString()
|
fixPaddingStatusbar(binding?.downloadRoot)
|
||||||
if (url.isNullOrEmpty()) {
|
}
|
||||||
showToast(activity, R.string.error_invalid_url, Toast.LENGTH_SHORT)
|
|
||||||
} else {
|
|
||||||
val referer = dialog.stream_referer.text?.toString()
|
|
||||||
|
|
||||||
|
private fun handleItemClick(click: DownloadHeaderClickEvent) {
|
||||||
|
when (click.action) {
|
||||||
|
DOWNLOAD_ACTION_GO_TO_CHILD -> {
|
||||||
|
if (click.data.type.isEpisodeBased()) {
|
||||||
|
val folder =
|
||||||
|
getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
|
||||||
activity?.navigate(
|
activity?.navigate(
|
||||||
R.id.global_to_navigation_player,
|
R.id.action_navigation_downloads_to_navigation_download_child,
|
||||||
GeneratorPlayer.newInstance(
|
DownloadChildFragment.newInstance(click.data.name, folder)
|
||||||
LinkGenerator(
|
|
||||||
listOf(url),
|
|
||||||
extract = true,
|
|
||||||
referer = referer,
|
|
||||||
isM3u8 = dialog.hls_switch?.isChecked
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog.dismissSafe(activity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.cancel_btt?.setOnClickListener {
|
DOWNLOAD_ACTION_LOAD_RESULT -> {
|
||||||
|
activity?.loadResult(click.data.url, click.data.apiName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSelectedChange(selected: MutableSet<Int>) {
|
||||||
|
if (selected.isNotEmpty()) {
|
||||||
|
binding?.downloadDeleteAppbar?.isVisible = true
|
||||||
|
binding?.downloadStorageAppbar?.isVisible = false
|
||||||
|
activity?.attachBackPressedCallback {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnDelete?.setOnClickListener {
|
||||||
|
context?.let { ctx ->
|
||||||
|
downloadsViewModel.handleMultiDelete(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnCancel?.setOnClickListener {
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.btnToggleAll?.setOnClickListener {
|
||||||
|
val allSelected = downloadsViewModel.isAllSelected()
|
||||||
|
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
|
||||||
|
if (allSelected) {
|
||||||
|
adapter?.notifySelectionStates()
|
||||||
|
downloadsViewModel.clearSelectedItems()
|
||||||
|
} else {
|
||||||
|
adapter?.notifyAllSelected()
|
||||||
|
downloadsViewModel.selectAllItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadsViewModel.setIsMultiDeleteState(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDeleteButton(count: Int, selectedBytes: Long) {
|
||||||
|
val formattedSize = formatShortFileSize(context, selectedBytes)
|
||||||
|
binding?.btnDelete?.text =
|
||||||
|
getString(R.string.delete_format).format(count, formattedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStorageInfo(
|
||||||
|
context: Context,
|
||||||
|
bytes: Long,
|
||||||
|
@StringRes stringRes: Int,
|
||||||
|
textView: TextView?,
|
||||||
|
view: View?
|
||||||
|
) {
|
||||||
|
textView?.text = getString(R.string.storage_size_format).format(
|
||||||
|
getString(stringRes),
|
||||||
|
formatShortFileSize(context, bytes)
|
||||||
|
)
|
||||||
|
view?.setLayoutWidth(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLocalVideo() {
|
||||||
|
val intent = Intent()
|
||||||
|
.setAction(Intent.ACTION_GET_CONTENT)
|
||||||
|
.setType("video/*")
|
||||||
|
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
.addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access
|
||||||
|
normalSafeApiCall {
|
||||||
|
videoResultLauncher.launch(
|
||||||
|
Intent.createChooser(
|
||||||
|
intent,
|
||||||
|
getString(R.string.open_local_video)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showStreamInputDialog(context: Context) {
|
||||||
|
val dialog = Dialog(context, R.style.AlertDialogCustom)
|
||||||
|
val binding = StreamInputBinding.inflate(dialog.layoutInflater)
|
||||||
|
dialog.setContentView(binding.root)
|
||||||
|
dialog.show()
|
||||||
|
|
||||||
|
var preventAutoSwitching = false
|
||||||
|
binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
|
||||||
|
|
||||||
|
binding.streamReferer.doOnTextChanged { text, _, _, _ ->
|
||||||
|
if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
(activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
|
||||||
|
0
|
||||||
|
)?.text?.toString()?.let { copy ->
|
||||||
|
val fixedText = copy.trim()
|
||||||
|
binding.streamUrl.setText(fixedText)
|
||||||
|
activateSwitchOnHls(fixedText, binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.applyBtt.setOnClickListener {
|
||||||
|
val url = binding.streamUrl.text?.toString()
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
|
||||||
|
} else {
|
||||||
|
val referer = binding.streamReferer.text?.toString()
|
||||||
|
activity?.navigate(
|
||||||
|
R.id.global_to_navigation_player,
|
||||||
|
GeneratorPlayer.newInstance(
|
||||||
|
LinkGenerator(
|
||||||
|
listOf(BasicLink(url)),
|
||||||
|
extract = true,
|
||||||
|
referer = referer,
|
||||||
|
isM3u8 = binding.hlsSwitch.isChecked
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
dialog.dismissSafe(activity)
|
dialog.dismissSafe(activity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
download_list?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
|
||||||
val dy = scrollY - oldScrollY
|
|
||||||
if (dy > 0) { //check for scroll down
|
|
||||||
download_stream_button?.shrink() // hide
|
|
||||||
} else if (dy < -5) {
|
|
||||||
download_stream_button?.extend() // show
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadsViewModel.updateList(requireContext())
|
|
||||||
|
|
||||||
context?.fixPaddingStatusbar(download_root)
|
binding.cancelBtt.setOnClickListener {
|
||||||
|
dialog.dismissSafe(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) {
|
||||||
|
binding.hlsSwitch.isChecked = normalSafeApiCall {
|
||||||
|
URI(text).path?.substringAfterLast(".")?.contains("m3u")
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleScroll(dy: Int) {
|
||||||
|
if (dy > 0) {
|
||||||
|
binding?.downloadStreamButton?.shrink()
|
||||||
|
} else if (dy < -5) {
|
||||||
|
binding?.downloadStreamButton?.extend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open local video from files using content provider x safeFile
|
||||||
|
private val videoResultLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
|
||||||
|
playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.widget.ContentLoadingProgressBar
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
|
||||||
import kotlinx.android.synthetic.main.download_header_episode.view.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
data class VisualDownloadHeaderCached(
|
|
||||||
val currentOngoingDownloads: Int,
|
|
||||||
val totalDownloads: Int,
|
|
||||||
val totalBytes: Long,
|
|
||||||
val currentBytes: Long,
|
|
||||||
val data: VideoDownloadHelper.DownloadHeaderCached,
|
|
||||||
val child: VideoDownloadHelper.DownloadEpisodeCached?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DownloadHeaderClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadHeaderCached)
|
|
||||||
|
|
||||||
class DownloadHeaderAdapter(
|
|
||||||
var cardList: List<VisualDownloadHeaderCached>,
|
|
||||||
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
|
|
||||||
private val movieClickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
||||||
|
|
||||||
private val mBoundViewHolders: HashSet<DownloadButtonViewHolder> = HashSet()
|
|
||||||
private fun getAllBoundViewHolders(): Set<DownloadButtonViewHolder?>? {
|
|
||||||
return Collections.unmodifiableSet(mBoundViewHolders)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun killAdapter() {
|
|
||||||
getAllBoundViewHolders()?.forEach { view ->
|
|
||||||
view?.downloadButton?.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.downloadButton.dispose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.downloadButton.dispose()
|
|
||||||
mBoundViewHolders.remove(holder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
|
|
||||||
if (holder is DownloadButtonViewHolder) {
|
|
||||||
holder.reattachDownloadButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
return DownloadHeaderViewHolder(
|
|
||||||
LayoutInflater.from(parent.context).inflate(R.layout.download_header_episode, parent, false),
|
|
||||||
clickCallback,
|
|
||||||
movieClickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is DownloadHeaderViewHolder -> {
|
|
||||||
holder.bind(cardList[position])
|
|
||||||
mBoundViewHolders.add(holder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return cardList.size
|
|
||||||
}
|
|
||||||
|
|
||||||
class DownloadHeaderViewHolder
|
|
||||||
constructor(
|
|
||||||
itemView: View,
|
|
||||||
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
|
|
||||||
private val movieClickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder {
|
|
||||||
override var downloadButton = EasyDownloadButton()
|
|
||||||
|
|
||||||
private val poster: ImageView? = itemView.download_header_poster
|
|
||||||
private val title: TextView = itemView.download_header_title
|
|
||||||
private val extraInfo: TextView = itemView.download_header_info
|
|
||||||
private val holder: CardView = itemView.episode_holder
|
|
||||||
|
|
||||||
private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded
|
|
||||||
private val downloadImage: ImageView = itemView.download_header_episode_download
|
|
||||||
private val normalImage: ImageView = itemView.download_header_goto_child
|
|
||||||
private var localCard: VisualDownloadHeaderCached? = null
|
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
|
||||||
fun bind(card: VisualDownloadHeaderCached) {
|
|
||||||
localCard = card
|
|
||||||
val d = card.data
|
|
||||||
|
|
||||||
poster?.setImage(d.poster)
|
|
||||||
poster?.setOnClickListener {
|
|
||||||
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
|
|
||||||
}
|
|
||||||
|
|
||||||
title.text = d.name
|
|
||||||
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
|
|
||||||
|
|
||||||
//val isMovie = d.type.isMovieType()
|
|
||||||
if (card.child != null) {
|
|
||||||
downloadBar.visibility = View.VISIBLE
|
|
||||||
downloadImage.visibility = View.VISIBLE
|
|
||||||
normalImage.visibility = View.GONE
|
|
||||||
/*setUpButton(
|
|
||||||
card.currentBytes,
|
|
||||||
card.totalBytes,
|
|
||||||
downloadBar,
|
|
||||||
downloadImage,
|
|
||||||
extraInfo,
|
|
||||||
card.child,
|
|
||||||
movieClickCallback
|
|
||||||
)*/
|
|
||||||
|
|
||||||
holder.setOnClickListener {
|
|
||||||
movieClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
downloadBar.visibility = View.GONE
|
|
||||||
downloadImage.visibility = View.GONE
|
|
||||||
normalImage.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
try {
|
|
||||||
extraInfo.text =
|
|
||||||
extraInfo.context.getString(R.string.extra_info_format).format(
|
|
||||||
card.totalDownloads,
|
|
||||||
if (card.totalDownloads == 1) extraInfo.context.getString(R.string.episode) else extraInfo.context.getString(
|
|
||||||
R.string.episodes
|
|
||||||
),
|
|
||||||
mbString
|
|
||||||
)
|
|
||||||
} catch (t : Throwable) {
|
|
||||||
// you probably formatted incorrectly
|
|
||||||
extraInfo.text = "Error"
|
|
||||||
logError(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
holder.setOnClickListener {
|
|
||||||
clickCallback.invoke(DownloadHeaderClickEvent(0, d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun reattachDownloadButton() {
|
|
||||||
downloadButton.dispose()
|
|
||||||
val card = localCard
|
|
||||||
if (card?.child != null) {
|
|
||||||
downloadButton.setUpButton(
|
|
||||||
card.currentBytes,
|
|
||||||
card.totalBytes,
|
|
||||||
downloadBar,
|
|
||||||
downloadImage,
|
|
||||||
extraInfo,
|
|
||||||
card.child,
|
|
||||||
movieClickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +1,439 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
package com.lagradost.cloudstream3.ui.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.lagradost.cloudstream3.isMovieType
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.isEpisodeBased
|
||||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class DownloadViewModel : ViewModel() {
|
class DownloadViewModel : ViewModel() {
|
||||||
private val _noDownloadsText = MutableLiveData<String>().apply {
|
|
||||||
value = ""
|
|
||||||
}
|
|
||||||
val noDownloadsText: LiveData<String> = _noDownloadsText
|
|
||||||
|
|
||||||
private val _headerCards =
|
private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
|
||||||
MutableLiveData<List<VisualDownloadHeaderCached>>().apply { listOf<VisualDownloadHeaderCached>() }
|
val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
|
||||||
val headerCards: LiveData<List<VisualDownloadHeaderCached>> = _headerCards
|
|
||||||
|
private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
|
||||||
|
val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
|
||||||
|
|
||||||
private val _usedBytes = MutableLiveData<Long>()
|
private val _usedBytes = MutableLiveData<Long>()
|
||||||
private val _availableBytes = MutableLiveData<Long>()
|
|
||||||
private val _downloadBytes = MutableLiveData<Long>()
|
|
||||||
|
|
||||||
val usedBytes: LiveData<Long> = _usedBytes
|
val usedBytes: LiveData<Long> = _usedBytes
|
||||||
|
|
||||||
|
private val _availableBytes = MutableLiveData<Long>()
|
||||||
val availableBytes: LiveData<Long> = _availableBytes
|
val availableBytes: LiveData<Long> = _availableBytes
|
||||||
|
|
||||||
|
private val _downloadBytes = MutableLiveData<Long>()
|
||||||
val downloadBytes: LiveData<Long> = _downloadBytes
|
val downloadBytes: LiveData<Long> = _downloadBytes
|
||||||
|
|
||||||
fun updateList(context: Context) = viewModelScope.launchSafe {
|
private val _selectedBytes = MutableLiveData<Long>(0)
|
||||||
val children = withContext(Dispatchers.IO) {
|
val selectedBytes: LiveData<Long> = _selectedBytes
|
||||||
val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
|
||||||
headers.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
|
||||||
.distinctBy { it.id } // Remove duplicates
|
|
||||||
}
|
|
||||||
|
|
||||||
// parentId : bytes
|
private val _isMultiDeleteState = MutableLiveData(false)
|
||||||
val totalBytesUsedByChild = HashMap<Int, Long>()
|
val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
|
||||||
// parentId : bytes
|
|
||||||
val currentBytesUsedByChild = HashMap<Int, Long>()
|
|
||||||
// parentId : downloadsCount
|
|
||||||
val totalDownloads = HashMap<Int, Int>()
|
|
||||||
|
|
||||||
|
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
|
||||||
|
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
|
||||||
|
|
||||||
// Gets all children downloads
|
private var previousVisual: List<VisualDownloadCached>? = null
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
for (c in children) {
|
|
||||||
val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue
|
|
||||||
|
|
||||||
if (childFile.fileLength <= 1) continue
|
fun setIsMultiDeleteState(value: Boolean) {
|
||||||
val len = childFile.totalBytes
|
_isMultiDeleteState.postValue(value)
|
||||||
val flen = childFile.fileLength
|
}
|
||||||
|
|
||||||
totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len
|
fun addSelected(itemId: Int) {
|
||||||
currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen
|
updateSelectedItems { it.add(itemId) }
|
||||||
totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1
|
}
|
||||||
|
|
||||||
|
fun removeSelected(itemId: Int) {
|
||||||
|
updateSelectedItems { it.remove(itemId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectAllItems() {
|
||||||
|
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||||
|
updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelectedItems() {
|
||||||
|
// We need this to be done immediately
|
||||||
|
// so we can't use postValue
|
||||||
|
_selectedItemIds.value = mutableSetOf()
|
||||||
|
updateSelectedItems { it.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAllSelected(): Boolean {
|
||||||
|
val currentSelected = selectedItemIds.value ?: return false
|
||||||
|
val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
|
||||||
|
return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
|
||||||
|
val currentSelected = selectedItemIds.value ?: mutableSetOf()
|
||||||
|
action(currentSelected)
|
||||||
|
_selectedItemIds.postValue(currentSelected)
|
||||||
|
updateSelectedBytes()
|
||||||
|
updateSelectedCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSelectedBytes() = viewModelScope.launchSafe {
|
||||||
|
val selectedItemsList = getSelectedItemsData() ?: return@launchSafe
|
||||||
|
val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes }
|
||||||
|
_selectedBytes.postValue(totalSelectedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSelectedCards() = viewModelScope.launchSafe {
|
||||||
|
val currentSelected = selectedItemIds.value ?: return@launchSafe
|
||||||
|
|
||||||
|
headerCards.value?.let { headers ->
|
||||||
|
headers.forEach { header ->
|
||||||
|
header.isSelected = header.data.id in currentSelected
|
||||||
}
|
}
|
||||||
|
_headerCards.postValue(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cached = withContext(Dispatchers.IO) { // wont fetch useless keys
|
childCards.value?.let { children ->
|
||||||
totalDownloads.entries.filter { it.value > 0 }.mapNotNull {
|
children.forEach { child ->
|
||||||
context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
child.isSelected = child.data.id in currentSelected
|
||||||
DOWNLOAD_HEADER_CACHE,
|
|
||||||
it.key.toString()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
_childCards.postValue(children)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
|
||||||
val visual = withContext(Dispatchers.IO) {
|
val visual = withContext(Dispatchers.IO) {
|
||||||
cached.mapNotNull { // TODO FIX
|
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
val downloads = totalDownloads[it.id] ?: 0
|
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
|
||||||
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
.distinctBy { it.id } // Remove duplicates
|
||||||
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
|
||||||
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
|
||||||
val movieEpisode =
|
calculateDownloadStats(context, children)
|
||||||
if (!it.type.isMovieType()) null
|
|
||||||
else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
|
||||||
DOWNLOAD_EPISODE_CACHE,
|
.mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
|
||||||
getFolderName(it.id.toString(), it.id.toString())
|
|
||||||
)
|
createVisualDownloadList(
|
||||||
VisualDownloadHeaderCached(
|
context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
|
||||||
0,
|
)
|
||||||
downloads,
|
|
||||||
bytes,
|
|
||||||
currentBytes,
|
|
||||||
it,
|
|
||||||
movieEpisode
|
|
||||||
)
|
|
||||||
}.sortedBy {
|
|
||||||
(it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0)
|
|
||||||
} // episode sorting by episode, lowest to highest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (visual != previousVisual) {
|
||||||
|
previousVisual = visual
|
||||||
|
updateStorageStats(visual)
|
||||||
|
_headerCards.postValue(visual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDownloadStats(
|
||||||
|
context: Context,
|
||||||
|
children: List<VideoDownloadHelper.DownloadEpisodeCached>
|
||||||
|
): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
|
||||||
|
// parentId : bytes
|
||||||
|
val totalBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
|
// parentId : bytes
|
||||||
|
val currentBytesUsedByChild = mutableMapOf<Int, Long>()
|
||||||
|
// parentId : downloadsCount
|
||||||
|
val totalDownloads = mutableMapOf<Int, Int>()
|
||||||
|
|
||||||
|
children.forEach { child ->
|
||||||
|
val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
|
||||||
|
if (childFile.fileLength <= 1) return@forEach
|
||||||
|
|
||||||
|
val len = childFile.totalBytes
|
||||||
|
val flen = childFile.fileLength
|
||||||
|
|
||||||
|
totalBytesUsedByChild.merge(child.parentId, len, Long::plus)
|
||||||
|
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
|
||||||
|
totalDownloads.merge(child.parentId, 1, Int::plus)
|
||||||
|
}
|
||||||
|
return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createVisualDownloadList(
|
||||||
|
context: Context,
|
||||||
|
cached: List<VideoDownloadHelper.DownloadHeaderCached>,
|
||||||
|
totalBytesUsedByChild: Map<Int, Long>,
|
||||||
|
currentBytesUsedByChild: Map<Int, Long>,
|
||||||
|
totalDownloads: Map<Int, Int>
|
||||||
|
): List<VisualDownloadCached.Header> {
|
||||||
|
return cached.mapNotNull {
|
||||||
|
val downloads = totalDownloads[it.id] ?: 0
|
||||||
|
val bytes = totalBytesUsedByChild[it.id] ?: 0
|
||||||
|
val currentBytes = currentBytesUsedByChild[it.id] ?: 0
|
||||||
|
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
|
||||||
|
|
||||||
|
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||||
|
val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||||
|
DOWNLOAD_EPISODE_CACHE,
|
||||||
|
getFolderName(it.id.toString(), it.id.toString())
|
||||||
|
)
|
||||||
|
|
||||||
|
VisualDownloadCached.Header(
|
||||||
|
currentBytes = currentBytes,
|
||||||
|
totalBytes = bytes,
|
||||||
|
data = it,
|
||||||
|
child = movieEpisode,
|
||||||
|
currentOngoingDownloads = 0,
|
||||||
|
totalDownloads = downloads,
|
||||||
|
isSelected = isSelected,
|
||||||
|
)
|
||||||
|
// Prevent order being almost completely random,
|
||||||
|
// making things difficult to find.
|
||||||
|
}.sortedWith(compareBy<VisualDownloadCached.Header> {
|
||||||
|
// Sort by isEpisodeBased() ascending. We put those that
|
||||||
|
// are episode based at the bottom for UI purposes and to
|
||||||
|
// make it easier to find by grouping them together.
|
||||||
|
it.data.type.isEpisodeBased()
|
||||||
|
}.thenBy {
|
||||||
|
// Then we sort alphabetically by name (case-insensitive).
|
||||||
|
// Again, we do this to make things easier to find.
|
||||||
|
it.data.name.lowercase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
|
||||||
|
val visual = withContext(Dispatchers.IO) {
|
||||||
|
context.getKeys(folder).mapNotNull { key ->
|
||||||
|
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
|
||||||
|
}.mapNotNull {
|
||||||
|
val isSelected = selectedItemIds.value?.contains(it.id) ?: false
|
||||||
|
val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
|
||||||
|
VisualDownloadCached.Child(
|
||||||
|
currentBytes = info.fileLength,
|
||||||
|
totalBytes = info.totalBytes,
|
||||||
|
isSelected = isSelected,
|
||||||
|
data = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.sortedWith(compareBy(
|
||||||
|
// Sort by season first, and then by episode number,
|
||||||
|
// to ensure sorting is consistent.
|
||||||
|
{ it.data.season ?: 0 },
|
||||||
|
{ it.data.episode }
|
||||||
|
))
|
||||||
|
|
||||||
|
if (previousVisual != visual) {
|
||||||
|
previousVisual = visual
|
||||||
|
_childCards.postValue(visual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
|
||||||
|
val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||||
|
val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
|
||||||
|
_headerCards.postValue(updatedHeaders)
|
||||||
|
_childCards.postValue(updatedChildren)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
|
||||||
try {
|
try {
|
||||||
val stat = StatFs(Environment.getExternalStorageDirectory().path)
|
val stat = StatFs(Environment.getExternalStorageDirectory().path)
|
||||||
|
val localBytesAvailable = stat.availableBytes
|
||||||
val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong
|
|
||||||
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
|
val localTotalBytes = stat.blockSizeLong * stat.blockCountLong
|
||||||
val localDownloadedBytes = visual.sumOf { it.totalBytes }
|
val localDownloadedBytes = visual.sumOf { it.totalBytes }
|
||||||
|
val localUsedBytes = localTotalBytes - localBytesAvailable
|
||||||
_usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes)
|
_usedBytes.postValue(localUsedBytes)
|
||||||
_availableBytes.postValue(localBytesAvailable)
|
_availableBytes.postValue(localBytesAvailable)
|
||||||
_downloadBytes.postValue(localDownloadedBytes)
|
_downloadBytes.postValue(localDownloadedBytes)
|
||||||
} catch (t : Throwable) {
|
} catch (t: Throwable) {
|
||||||
_downloadBytes.postValue(0)
|
_downloadBytes.postValue(0)
|
||||||
logError(t)
|
logError(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
_headerCards.postValue(visual)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun handleMultiDelete(context: Context) = viewModelScope.launchSafe {
|
||||||
|
val selectedItemsList = getSelectedItemsData().orEmpty()
|
||||||
|
val deleteData = processSelectedItems(context, selectedItemsList)
|
||||||
|
val message = buildDeleteMessage(context, deleteData)
|
||||||
|
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSingleDelete(
|
||||||
|
context: Context,
|
||||||
|
itemId: Int
|
||||||
|
) = viewModelScope.launchSafe {
|
||||||
|
val itemData = getItemDataFromId(itemId)
|
||||||
|
val deleteData = processSelectedItems(context, itemData)
|
||||||
|
val message = buildDeleteMessage(context, deleteData)
|
||||||
|
showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processSelectedItems(
|
||||||
|
context: Context,
|
||||||
|
selectedItemsList: List<VisualDownloadCached>
|
||||||
|
): DeleteData {
|
||||||
|
val names = mutableListOf<String>()
|
||||||
|
val seriesNames = mutableListOf<String>()
|
||||||
|
|
||||||
|
val ids = mutableSetOf<Int>()
|
||||||
|
val parentIds = mutableSetOf<Int>()
|
||||||
|
|
||||||
|
var parentName: String? = null
|
||||||
|
|
||||||
|
selectedItemsList.forEach { item ->
|
||||||
|
when (item) {
|
||||||
|
is VisualDownloadCached.Header -> {
|
||||||
|
if (item.data.type.isEpisodeBased()) {
|
||||||
|
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
|
||||||
|
.mapNotNull {
|
||||||
|
context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter { it.parentId == item.data.id }
|
||||||
|
.map { it.id }
|
||||||
|
ids.addAll(episodes)
|
||||||
|
parentIds.add(item.data.id)
|
||||||
|
|
||||||
|
val episodeInfo = "${item.data.name} (${item.totalDownloads} ${
|
||||||
|
context.resources.getQuantityString(
|
||||||
|
R.plurals.episodes,
|
||||||
|
item.totalDownloads
|
||||||
|
).lowercase()
|
||||||
|
})"
|
||||||
|
seriesNames.add(episodeInfo)
|
||||||
|
} else {
|
||||||
|
ids.add(item.data.id)
|
||||||
|
names.add(item.data.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is VisualDownloadCached.Child -> {
|
||||||
|
ids.add(item.data.id)
|
||||||
|
val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
|
||||||
|
DOWNLOAD_HEADER_CACHE,
|
||||||
|
item.data.parentId.toString()
|
||||||
|
)
|
||||||
|
parentName = parent?.name
|
||||||
|
names.add(
|
||||||
|
context.getNameFull(
|
||||||
|
item.data.name,
|
||||||
|
item.data.episode,
|
||||||
|
item.data.season
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeleteData(ids, parentIds, seriesNames, names, parentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDeleteMessage(
|
||||||
|
context: Context,
|
||||||
|
data: DeleteData
|
||||||
|
): String {
|
||||||
|
val formattedNames = data.names.sortedBy { it.lowercase() }
|
||||||
|
.joinToString(separator = "\n") { "• $it" }
|
||||||
|
val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() }
|
||||||
|
.joinToString(separator = "\n") { "• $it" }
|
||||||
|
|
||||||
|
return when {
|
||||||
|
data.ids.count() == 1 -> {
|
||||||
|
context.getString(R.string.delete_message).format(
|
||||||
|
data.names.firstOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
|
||||||
|
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.parentName != null && data.names.isNotEmpty() -> {
|
||||||
|
context.getString(R.string.delete_message_series_episodes)
|
||||||
|
.format(data.parentName, formattedNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.seriesNames.isNotEmpty() -> {
|
||||||
|
val seriesSection = context.getString(R.string.delete_message_series_section)
|
||||||
|
.format(formattedSeriesNames)
|
||||||
|
context.getString(R.string.delete_message_multiple)
|
||||||
|
.format(formattedNames) + "\n\n" + seriesSection
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> context.getString(R.string.delete_message_multiple).format(formattedNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDeleteConfirmationDialog(
|
||||||
|
context: Context,
|
||||||
|
message: String,
|
||||||
|
ids: Set<Int>,
|
||||||
|
parentIds: Set<Int>
|
||||||
|
) {
|
||||||
|
val builder = AlertDialog.Builder(context)
|
||||||
|
val dialogClickListener =
|
||||||
|
DialogInterface.OnClickListener { _, which ->
|
||||||
|
when (which) {
|
||||||
|
DialogInterface.BUTTON_POSITIVE -> {
|
||||||
|
viewModelScope.launchSafe {
|
||||||
|
setIsMultiDeleteState(false)
|
||||||
|
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
|
||||||
|
// We always remove parent because if we are deleting from here
|
||||||
|
// and we have it as non-empty, it was triggered on
|
||||||
|
// parent header card
|
||||||
|
removeItems(successfulIds + parentIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogInterface.BUTTON_NEGATIVE -> {
|
||||||
|
// Do nothing on cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val title = if (ids.count() == 1) {
|
||||||
|
R.string.delete_file
|
||||||
|
} else R.string.delete_files
|
||||||
|
builder.setTitle(title)
|
||||||
|
.setMessage(message)
|
||||||
|
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||||
|
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||||
|
.show().setDefaultFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectedItemsData(): List<VisualDownloadCached>? {
|
||||||
|
val headers = headerCards.value.orEmpty()
|
||||||
|
val children = childCards.value.orEmpty()
|
||||||
|
|
||||||
|
return selectedItemIds.value?.mapNotNull { id ->
|
||||||
|
headers.find { it.data.id == id } ?: children.find { it.data.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
|
||||||
|
val headers = headerCards.value.orEmpty()
|
||||||
|
val children = childCards.value.orEmpty()
|
||||||
|
|
||||||
|
return (headers + children).filter { it.data.id == itemId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class DeleteData(
|
||||||
|
val ids: Set<Int>,
|
||||||
|
val parentIds: Set<Int>,
|
||||||
|
val seriesNames: List<String>,
|
||||||
|
val names: List<String>,
|
||||||
|
val parentName: String?
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
package com.lagradost.cloudstream3.ui.download
|
|
||||||
|
|
||||||
import android.animation.ObjectAnimator
|
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
|
||||||
import android.view.View
|
|
||||||
import android.view.animation.DecelerateInterpolator
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.core.widget.ContentLoadingProgressBar
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import com.lagradost.cloudstream3.R
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines
|
|
||||||
import com.lagradost.cloudstream3.utils.IDisposable
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
|
||||||
|
|
||||||
class EasyDownloadButton : IDisposable {
|
|
||||||
interface IMinimumData {
|
|
||||||
val id: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null
|
|
||||||
private var _imageChangeCallback: ((Pair<Int, String>) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
try {
|
|
||||||
_clickCallback = null
|
|
||||||
_imageChangeCallback = null
|
|
||||||
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it }
|
|
||||||
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var downloadProgressEventListener: ((Triple<Int, Long, Long>) -> Unit)? = null
|
|
||||||
private var downloadStatusEventListener: ((Pair<Int, VideoDownloadManager.DownloadType>) -> Unit)? =
|
|
||||||
null
|
|
||||||
|
|
||||||
fun setUpMaterialButton(
|
|
||||||
setupCurrentBytes: Long?,
|
|
||||||
setupTotalBytes: Long?,
|
|
||||||
progressBar: ContentLoadingProgressBar,
|
|
||||||
downloadButton: MaterialButton,
|
|
||||||
textView: TextView?,
|
|
||||||
data: IMinimumData,
|
|
||||||
clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) {
|
|
||||||
setUpDownloadButton(
|
|
||||||
setupCurrentBytes,
|
|
||||||
setupTotalBytes,
|
|
||||||
progressBar,
|
|
||||||
textView,
|
|
||||||
data,
|
|
||||||
downloadButton,
|
|
||||||
{
|
|
||||||
downloadButton.setIconResource(it.first)
|
|
||||||
downloadButton.text = it.second
|
|
||||||
},
|
|
||||||
clickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUpMoreButton(
|
|
||||||
setupCurrentBytes: Long?,
|
|
||||||
setupTotalBytes: Long?,
|
|
||||||
progressBar: ContentLoadingProgressBar,
|
|
||||||
downloadImage: ImageView,
|
|
||||||
textView: TextView?,
|
|
||||||
textViewProgress: TextView?,
|
|
||||||
clickableView: View,
|
|
||||||
isTextPercentage: Boolean,
|
|
||||||
data: IMinimumData,
|
|
||||||
clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) {
|
|
||||||
setUpDownloadButton(
|
|
||||||
setupCurrentBytes,
|
|
||||||
setupTotalBytes,
|
|
||||||
progressBar,
|
|
||||||
textViewProgress,
|
|
||||||
data,
|
|
||||||
clickableView,
|
|
||||||
{ (image, text) ->
|
|
||||||
downloadImage.isVisible = textViewProgress?.isGone ?: true
|
|
||||||
downloadImage.setImageResource(image)
|
|
||||||
textView?.text = text
|
|
||||||
},
|
|
||||||
clickCallback, isTextPercentage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setUpButton(
|
|
||||||
setupCurrentBytes: Long?,
|
|
||||||
setupTotalBytes: Long?,
|
|
||||||
progressBar: ContentLoadingProgressBar,
|
|
||||||
downloadImage: ImageView,
|
|
||||||
textView: TextView?,
|
|
||||||
data: IMinimumData,
|
|
||||||
clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
) {
|
|
||||||
setUpDownloadButton(
|
|
||||||
setupCurrentBytes,
|
|
||||||
setupTotalBytes,
|
|
||||||
progressBar,
|
|
||||||
textView,
|
|
||||||
data,
|
|
||||||
downloadImage,
|
|
||||||
{
|
|
||||||
downloadImage.setImageResource(it.first)
|
|
||||||
},
|
|
||||||
clickCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setUpDownloadButton(
|
|
||||||
setupCurrentBytes: Long?,
|
|
||||||
setupTotalBytes: Long?,
|
|
||||||
progressBar: ContentLoadingProgressBar,
|
|
||||||
textView: TextView?,
|
|
||||||
data: IMinimumData,
|
|
||||||
downloadView: View,
|
|
||||||
downloadImageChangeCallback: (Pair<Int, String>) -> Unit,
|
|
||||||
clickCallback: (DownloadClickEvent) -> Unit,
|
|
||||||
isTextPercentage: Boolean = false
|
|
||||||
) {
|
|
||||||
_clickCallback = clickCallback
|
|
||||||
_imageChangeCallback = downloadImageChangeCallback
|
|
||||||
var lastState: VideoDownloadManager.DownloadType? = null
|
|
||||||
var currentBytes = setupCurrentBytes ?: 0
|
|
||||||
var totalBytes = setupTotalBytes ?: 0
|
|
||||||
var needImageUpdate = true
|
|
||||||
|
|
||||||
fun changeDownloadImage(state: VideoDownloadManager.DownloadType) {
|
|
||||||
lastState = state
|
|
||||||
if (currentBytes <= 0) needImageUpdate = true
|
|
||||||
val img = if (currentBytes > 0) {
|
|
||||||
when (state) {
|
|
||||||
VideoDownloadManager.DownloadType.IsPaused -> Pair(
|
|
||||||
R.drawable.ic_baseline_play_arrow_24,
|
|
||||||
R.string.download_paused
|
|
||||||
)
|
|
||||||
VideoDownloadManager.DownloadType.IsDownloading -> Pair(
|
|
||||||
R.drawable.netflix_pause,
|
|
||||||
R.string.downloading
|
|
||||||
)
|
|
||||||
else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Pair(R.drawable.netflix_download, R.string.download)
|
|
||||||
}
|
|
||||||
_imageChangeCallback?.invoke(
|
|
||||||
Pair(
|
|
||||||
img.first,
|
|
||||||
downloadView.context.getString(img.second)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) {
|
|
||||||
currentBytes = setCurrentBytes
|
|
||||||
totalBytes = setTotalBytes
|
|
||||||
|
|
||||||
if (currentBytes == 0L) {
|
|
||||||
changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped)
|
|
||||||
textView?.visibility = View.GONE
|
|
||||||
progressBar.visibility = View.GONE
|
|
||||||
} else {
|
|
||||||
if (lastState == VideoDownloadManager.DownloadType.IsStopped) {
|
|
||||||
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
|
|
||||||
}
|
|
||||||
textView?.visibility = View.VISIBLE
|
|
||||||
progressBar.visibility = View.VISIBLE
|
|
||||||
val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes)
|
|
||||||
val totalMbString = formatShortFileSize(textView?.context, setTotalBytes)
|
|
||||||
|
|
||||||
textView?.text =
|
|
||||||
if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
|
|
||||||
textView?.context?.getString(R.string.download_size_format)
|
|
||||||
?.format(currentMbString, totalMbString)
|
|
||||||
|
|
||||||
progressBar.let { bar ->
|
|
||||||
bar.max = (setTotalBytes / 1000).toInt()
|
|
||||||
|
|
||||||
if (animate) {
|
|
||||||
val animation: ObjectAnimator = ObjectAnimator.ofInt(
|
|
||||||
bar,
|
|
||||||
"progress",
|
|
||||||
bar.progress,
|
|
||||||
(setCurrentBytes / 1000).toInt()
|
|
||||||
)
|
|
||||||
animation.duration = 500
|
|
||||||
animation.setAutoCancel(true)
|
|
||||||
animation.interpolator = DecelerateInterpolator()
|
|
||||||
animation.start()
|
|
||||||
} else {
|
|
||||||
bar.progress = (setCurrentBytes / 1000).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fixDownloadedBytes(currentBytes, totalBytes, false)
|
|
||||||
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
|
|
||||||
|
|
||||||
downloadProgressEventListener = { downloadData: Triple<Int, Long, Long> ->
|
|
||||||
if (data.id == downloadData.first) {
|
|
||||||
if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME
|
|
||||||
Coroutines.runOnMainThread {
|
|
||||||
fixDownloadedBytes(downloadData.second, downloadData.third, true)
|
|
||||||
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadStatusEventListener =
|
|
||||||
{ downloadData: Pair<Int, VideoDownloadManager.DownloadType> ->
|
|
||||||
if (data.id == downloadData.first) {
|
|
||||||
if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME
|
|
||||||
Coroutines.runOnMainThread {
|
|
||||||
changeDownloadImage(downloadData.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it }
|
|
||||||
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it }
|
|
||||||
|
|
||||||
downloadView.setOnClickListener {
|
|
||||||
if (currentBytes <= 0 || totalBytes <= 0) {
|
|
||||||
_clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
|
|
||||||
} else {
|
|
||||||
val list = arrayListOf(
|
|
||||||
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
|
||||||
Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
|
|
||||||
)
|
|
||||||
|
|
||||||
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
|
|
||||||
if ((currentBytes * 100 / totalBytes) < 98) {
|
|
||||||
list.add(
|
|
||||||
if (lastState == VideoDownloadManager.DownloadType.IsDownloading)
|
|
||||||
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
|
|
||||||
else
|
|
||||||
Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
it.popupMenuNoIcons(
|
|
||||||
list
|
|
||||||
) {
|
|
||||||
_clickCallback?.invoke(DownloadClickEvent(itemId, data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadView.setOnLongClickListener {
|
|
||||||
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
|
|
||||||
return@setOnLongClickListener true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.button
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.LayoutRes
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.widget.ContentLoadingProgressBar
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
|
||||||
|
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
|
||||||
|
|
||||||
|
data class DownloadMetadata(
|
||||||
|
var id: Int,
|
||||||
|
var downloadedLength: Long,
|
||||||
|
var totalLength: Long,
|
||||||
|
var status: DownloadStatusTell? = null
|
||||||
|
) {
|
||||||
|
val progressPercentage: Long
|
||||||
|
get() = if (downloadedLength < 1024) 0 else maxOf(
|
||||||
|
0,
|
||||||
|
minOf(100, (downloadedLength * 100L) / (totalLength + 1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
FrameLayout(context, attributeSet) {
|
||||||
|
|
||||||
|
var persistentId: Int? = null // used to save sessions
|
||||||
|
|
||||||
|
lateinit var progressBar: ContentLoadingProgressBar
|
||||||
|
var progressText: TextView? = null
|
||||||
|
|
||||||
|
/* val gid: String? get() = sessionIdToGid[persistentId]
|
||||||
|
|
||||||
|
// used for resuming data
|
||||||
|
var _lastRequestOverride: UriRequest? = null
|
||||||
|
var lastRequest: UriRequest?
|
||||||
|
get() = _lastRequestOverride ?: sessionIdToLastRequest[persistentId]
|
||||||
|
set(value) {
|
||||||
|
_lastRequestOverride = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var files: List<AbstractClient.JsonFile> = emptyList() */
|
||||||
|
protected var isZeroBytes: Boolean = true
|
||||||
|
|
||||||
|
fun inflate(@LayoutRes layout: Int) {
|
||||||
|
inflate(context, layout, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
resetViewData()
|
||||||
|
}
|
||||||
|
|
||||||
|
var doSetProgress = true
|
||||||
|
|
||||||
|
open fun resetViewData() {
|
||||||
|
// lastRequest = null
|
||||||
|
isZeroBytes = true
|
||||||
|
doSetProgress = true
|
||||||
|
persistentId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentMetaData: DownloadMetadata =
|
||||||
|
DownloadMetadata(0, 0, 0, null)
|
||||||
|
|
||||||
|
fun setPersistentId(id: Int) {
|
||||||
|
persistentId = id
|
||||||
|
currentMetaData.id = id
|
||||||
|
|
||||||
|
if (!doSetProgress) return
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
|
||||||
|
|
||||||
|
mainWork {
|
||||||
|
if (savedData != null) {
|
||||||
|
val downloadedBytes = savedData.fileLength
|
||||||
|
val totalBytes = savedData.totalBytes
|
||||||
|
|
||||||
|
setProgress(downloadedBytes, totalBytes)
|
||||||
|
applyMetaData(id, downloadedBytes, totalBytes)
|
||||||
|
} else run { resetView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
|
||||||
|
|
||||||
|
fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell {
|
||||||
|
// some extra padding for just in case
|
||||||
|
return VideoDownloadManager.downloadStatus[id]
|
||||||
|
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) {
|
||||||
|
DownloadStatusTell.IsDone
|
||||||
|
} else DownloadStatusTell.IsPaused
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) {
|
||||||
|
val status = getStatus(id, downloadedBytes, totalBytes)
|
||||||
|
|
||||||
|
currentMetaData.apply {
|
||||||
|
this.id = id
|
||||||
|
this.downloadedLength = downloadedBytes
|
||||||
|
this.totalLength = totalBytes
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
setStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
|
||||||
|
isZeroBytes = downloadedBytes == 0L
|
||||||
|
progressBar.post {
|
||||||
|
val steps = 10000L
|
||||||
|
progressBar.max = steps.toInt()
|
||||||
|
// div by zero error and 1 byte off is ok impo
|
||||||
|
|
||||||
|
val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt()
|
||||||
|
|
||||||
|
val animation = ProgressBarAnimation(
|
||||||
|
progressBar,
|
||||||
|
progressBar.progress.toFloat(),
|
||||||
|
progress.toFloat()
|
||||||
|
).apply {
|
||||||
|
fillAfter = true
|
||||||
|
duration =
|
||||||
|
if (progress > progressBar.progress) // we don't want to animate backward changes in progress
|
||||||
|
100
|
||||||
|
else
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isZeroBytes) {
|
||||||
|
progressText?.isVisible = false
|
||||||
|
} else {
|
||||||
|
if (doSetProgress) {
|
||||||
|
progressText?.apply {
|
||||||
|
val currentFormattedSizeString =
|
||||||
|
formatShortFileSize(context, downloadedBytes)
|
||||||
|
val totalFormattedSizeString = formatShortFileSize(context, totalBytes)
|
||||||
|
text =
|
||||||
|
// if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
|
||||||
|
context?.getString(R.string.download_size_format)
|
||||||
|
?.format(currentFormattedSizeString, totalFormattedSizeString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressBar.startAnimation(animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadStatusEvent(data: Pair<Int, VideoDownloadManager.DownloadType>) {
|
||||||
|
val (id, status) = data
|
||||||
|
if (id == persistentId) {
|
||||||
|
currentMetaData.status = status
|
||||||
|
setStatus(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*fun downloadDeleteEvent(data: Int) {
|
||||||
|
|
||||||
|
}*/
|
||||||
|
|
||||||
|
/*fun downloadEvent(data: Pair<Int, VideoDownloadManager.DownloadActionType>) {
|
||||||
|
val (id, action) = data
|
||||||
|
|
||||||
|
}*/
|
||||||
|
|
||||||
|
fun downloadProgressEvent(data: Triple<Int, Long, Long>) {
|
||||||
|
val (id, bytesDownloaded, bytesTotal) = data
|
||||||
|
if (id == persistentId) {
|
||||||
|
currentMetaData.downloadedLength = bytesDownloaded
|
||||||
|
currentMetaData.totalLength = bytesTotal
|
||||||
|
|
||||||
|
setProgress(bytesDownloaded, bytesTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent
|
||||||
|
// VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
|
||||||
|
// VideoDownloadManager.downloadEvent += ::downloadEvent
|
||||||
|
VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent
|
||||||
|
|
||||||
|
val pid = persistentId
|
||||||
|
if (pid != null) {
|
||||||
|
// refresh in case of onDetachedFromWindow -> onAttachedToWindow while still being ???????
|
||||||
|
setPersistentId(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent
|
||||||
|
// VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
|
||||||
|
// VideoDownloadManager.downloadEvent -= ::downloadEvent
|
||||||
|
VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent
|
||||||
|
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No checks required. Arg will always include a download with current id
|
||||||
|
* */
|
||||||
|
abstract fun updateViewOnDownload(metadata: DownloadMetadata)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a clean slate again, might be useful in recyclerview?
|
||||||
|
* */
|
||||||
|
abstract fun resetView()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.button
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
|
|
||||||
|
class DownloadButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
PieFetchButton(context, attributeSet) {
|
||||||
|
|
||||||
|
private var mainText: TextView? = null
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
progressText = findViewById(R.id.result_movie_download_text_precentage)
|
||||||
|
mainText = findViewById(R.id.result_movie_download_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
|
mainText?.post {
|
||||||
|
val txt = when (status) {
|
||||||
|
DownloadStatusTell.IsPaused -> R.string.download_paused
|
||||||
|
DownloadStatusTell.IsDownloading -> R.string.downloading
|
||||||
|
DownloadStatusTell.IsDone -> R.string.downloaded
|
||||||
|
else -> R.string.download
|
||||||
|
}
|
||||||
|
mainText?.setText(txt)
|
||||||
|
}
|
||||||
|
super.setStatus(status)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setDefaultClickListener(
|
||||||
|
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
|
textView: TextView?,
|
||||||
|
callback: (DownloadClickEvent) -> Unit
|
||||||
|
) {
|
||||||
|
this.setDefaultClickListener(
|
||||||
|
this.findViewById<MaterialButton>(R.id.download_movie_button),
|
||||||
|
textView,
|
||||||
|
card,
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
override fun updateViewOnDownload(metadata: DownloadMetadata) {
|
||||||
|
super.updateViewOnDownload(metadata)
|
||||||
|
|
||||||
|
val isVis = metadata.progressPercentage > 0
|
||||||
|
progressText?.isVisible = isVis
|
||||||
|
if (isVis)
|
||||||
|
progressText?.text = "${metadata.progressPercentage}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.button
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.animation.AnimationUtils
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.MainThread
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
|
||||||
|
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
|
||||||
|
|
||||||
|
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
||||||
|
BaseFetchButton(context, attributeSet) {
|
||||||
|
|
||||||
|
private var waitingAnimation: Int = 0
|
||||||
|
private var animateWaiting: Boolean = false
|
||||||
|
private var activeOutline: Int = 0
|
||||||
|
private var nonActiveOutline: Int = 0
|
||||||
|
|
||||||
|
private var iconInit: Int = 0
|
||||||
|
private var iconError: Int = 0
|
||||||
|
private var iconComplete: Int = 0
|
||||||
|
private var iconActive: Int = 0
|
||||||
|
private var iconWaiting: Int = 0
|
||||||
|
private var iconRemoved: Int = 0
|
||||||
|
private var iconPaused: Int = 0
|
||||||
|
private var hideWhenIcon: Boolean = true
|
||||||
|
|
||||||
|
var progressDrawable: Int = 0
|
||||||
|
|
||||||
|
var overrideLayout: Int? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val fillArray = arrayOf(
|
||||||
|
R.drawable.circular_progress_bar_clockwise,
|
||||||
|
R.drawable.circular_progress_bar_counter_clockwise,
|
||||||
|
R.drawable.circular_progress_bar_small_to_large,
|
||||||
|
R.drawable.circular_progress_bar_top_to_bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressBarBackground: View
|
||||||
|
var statusView: ImageView
|
||||||
|
|
||||||
|
open fun onInflate() {}
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
|
||||||
|
try {
|
||||||
|
inflate(
|
||||||
|
overrideLayout ?: getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_layout,
|
||||||
|
R.layout.download_button_view
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(
|
||||||
|
"PieFetchButton", "Error inflating PieFetchButton, " +
|
||||||
|
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
progressBar = findViewById(R.id.progress_downloaded)
|
||||||
|
progressBarBackground = findViewById(R.id.progress_downloaded_background)
|
||||||
|
statusView = findViewById(R.id.image_download_status)
|
||||||
|
|
||||||
|
animateWaiting = getBoolean(
|
||||||
|
R.styleable.PieFetchButton_download_animate_waiting,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
hideWhenIcon = getBoolean(
|
||||||
|
R.styleable.PieFetchButton_download_hide_when_icon,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
waitingAnimation = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_waiting_animation,
|
||||||
|
R.anim.rotate_around_center_point
|
||||||
|
)
|
||||||
|
|
||||||
|
activeOutline = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
|
||||||
|
)
|
||||||
|
|
||||||
|
nonActiveOutline = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_outline_non_active,
|
||||||
|
R.drawable.circle_shape_dotted
|
||||||
|
)
|
||||||
|
iconInit = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_init, R.drawable.netflix_download
|
||||||
|
)
|
||||||
|
iconError = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_paused, R.drawable.download_icon_error
|
||||||
|
)
|
||||||
|
iconComplete = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done
|
||||||
|
)
|
||||||
|
iconPaused = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_paused, 0 // R.drawable.download_icon_pause
|
||||||
|
)
|
||||||
|
iconActive = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load
|
||||||
|
)
|
||||||
|
iconWaiting = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_waiting, 0
|
||||||
|
)
|
||||||
|
iconRemoved = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_icon_removed, R.drawable.netflix_download
|
||||||
|
)
|
||||||
|
|
||||||
|
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
|
||||||
|
|
||||||
|
progressDrawable = getResourceId(
|
||||||
|
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
|
||||||
|
|
||||||
|
recycle()
|
||||||
|
}
|
||||||
|
resetView()
|
||||||
|
onInflate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentStatus: DownloadStatusTell? = null
|
||||||
|
/*private fun getActivity(): Activity? {
|
||||||
|
var context = context
|
||||||
|
while (context is ContextWrapper) {
|
||||||
|
if (context is Activity) {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
context = context.baseContext
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun callback(event : DownloadClickEvent) {
|
||||||
|
handleDownloadClick(
|
||||||
|
getActivity(),
|
||||||
|
event
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
|
||||||
|
protected fun setDefaultClickListener(
|
||||||
|
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
|
callback: (DownloadClickEvent) -> Unit
|
||||||
|
) {
|
||||||
|
this.progressText = textView
|
||||||
|
this.setPersistentId(card.id)
|
||||||
|
view.setOnClickListener {
|
||||||
|
if (isZeroBytes) {
|
||||||
|
removeKey(KEY_RESUME_PACKAGES, card.id.toString())
|
||||||
|
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
|
||||||
|
// callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
|
||||||
|
} else {
|
||||||
|
val list = arrayListOf(
|
||||||
|
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
|
||||||
|
Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
|
||||||
|
)
|
||||||
|
|
||||||
|
currentMetaData.apply {
|
||||||
|
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
|
||||||
|
if (progressPercentage < 98) {
|
||||||
|
list.add(
|
||||||
|
if (status == VideoDownloadManager.DownloadType.IsDownloading)
|
||||||
|
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
|
||||||
|
else
|
||||||
|
Pair(
|
||||||
|
DOWNLOAD_ACTION_RESUME_DOWNLOAD,
|
||||||
|
R.string.popup_resume_download
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
it.popupMenuNoIcons(
|
||||||
|
list
|
||||||
|
) {
|
||||||
|
callback(DownloadClickEvent(itemId, card))
|
||||||
|
// callback.invoke(DownloadClickEvent(itemId, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setOnLongClickListener {
|
||||||
|
callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card))
|
||||||
|
|
||||||
|
// clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
|
||||||
|
return@setOnLongClickListener true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun setDefaultClickListener(
|
||||||
|
card: VideoDownloadHelper.DownloadEpisodeCached,
|
||||||
|
textView: TextView?,
|
||||||
|
callback: (DownloadClickEvent) -> Unit
|
||||||
|
) {
|
||||||
|
setDefaultClickListener(this, textView, card, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) {
|
||||||
|
this.setOnClickListener {
|
||||||
|
when (this.currentStatus) {
|
||||||
|
null -> {
|
||||||
|
setStatus(DownloadStatusTell.IsPending)
|
||||||
|
ioThread {
|
||||||
|
val request = requestGetter.invoke(this)
|
||||||
|
if (request.size == 1) {
|
||||||
|
performDownload(request.first())
|
||||||
|
} else if (request.isNotEmpty()) {
|
||||||
|
performFailQueueDownload(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadStatusTell.Paused -> {
|
||||||
|
resumeDownload()
|
||||||
|
}
|
||||||
|
DownloadStatusTell.Active -> {
|
||||||
|
pauseDownload()
|
||||||
|
}
|
||||||
|
DownloadStatusTell.Error -> {
|
||||||
|
redownload()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
@MainThread
|
||||||
|
private fun setStatusInternal(status: DownloadStatusTell?) {
|
||||||
|
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
|
||||||
|
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
|
||||||
|
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
|
||||||
|
progressBarBackground.startAnimation(animation)
|
||||||
|
} else {
|
||||||
|
progressBarBackground.clearAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressDrawable =
|
||||||
|
if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
|
||||||
|
|
||||||
|
progressBarBackground.background =
|
||||||
|
ContextCompat.getDrawable(context, progressDrawable)
|
||||||
|
|
||||||
|
val drawable =
|
||||||
|
getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) }
|
||||||
|
statusView.setImageDrawable(drawable)
|
||||||
|
val isDrawable = drawable != null
|
||||||
|
|
||||||
|
statusView.isVisible = isDrawable
|
||||||
|
val hide = hideWhenIcon && isDrawable
|
||||||
|
if (hide) {
|
||||||
|
progressBar.clearAnimation()
|
||||||
|
progressBarBackground.clearAnimation()
|
||||||
|
}
|
||||||
|
progressBarBackground.isGone = hide
|
||||||
|
progressBar.isGone = hide
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Also sets currentStatus */
|
||||||
|
override fun setStatus(status: DownloadStatusTell?) {
|
||||||
|
currentStatus = status
|
||||||
|
|
||||||
|
// Runs on the main thread, but also instant if it already is
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
try {
|
||||||
|
setStatusInternal(status)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t) // Just in case setStatusInternal throws because thread
|
||||||
|
progressBarBackground.post {
|
||||||
|
setStatusInternal(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progressBarBackground.post {
|
||||||
|
setStatusInternal(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resetView() {
|
||||||
|
setStatus(null)
|
||||||
|
currentMetaData = DownloadMetadata(0, 0, 0, null)
|
||||||
|
isZeroBytes = true
|
||||||
|
doSetProgress = true
|
||||||
|
progressBar.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateViewOnDownload(metadata: DownloadMetadata) {
|
||||||
|
|
||||||
|
val newStatus = metadata.status
|
||||||
|
|
||||||
|
if (newStatus == null) {
|
||||||
|
resetView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isDone =
|
||||||
|
newStatus == DownloadStatusTell.IsDone || (metadata.downloadedLength > 1024 && metadata.downloadedLength + 1024 >= metadata.totalLength)
|
||||||
|
|
||||||
|
if (isDone)
|
||||||
|
setStatus(DownloadStatusTell.IsDone)
|
||||||
|
else {
|
||||||
|
setProgress(metadata.downloadedLength, metadata.totalLength)
|
||||||
|
setStatus(newStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) {
|
||||||
|
DownloadStatusTell.IsPaused -> iconPaused
|
||||||
|
DownloadStatusTell.IsPending -> iconWaiting
|
||||||
|
DownloadStatusTell.IsDownloading -> iconActive
|
||||||
|
DownloadStatusTell.IsFailed -> iconError
|
||||||
|
DownloadStatusTell.IsDone -> iconComplete
|
||||||
|
DownloadStatusTell.IsStopped -> iconRemoved
|
||||||
|
else -> iconInit
|
||||||
|
}.takeIf { it != 0 }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.download.button
|
||||||
|
|
||||||
|
import android.view.animation.Animation
|
||||||
|
import android.view.animation.Transformation
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
|
||||||
|
class ProgressBarAnimation(
|
||||||
|
private val progressBar: ProgressBar,
|
||||||
|
private val from: Float,
|
||||||
|
private val to: Float
|
||||||
|
) :
|
||||||
|
Animation() {
|
||||||
|
override fun applyTransformation(interpolatedTime: Float, t: Transformation?) {
|
||||||
|
super.applyTransformation(interpolatedTime, t)
|
||||||
|
val value = from + (to - from) * interpolatedTime
|
||||||
|
progressBar.progress = value.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,146 +1,142 @@
|
||||||
package com.lagradost.cloudstream3.ui.home
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
import kotlinx.android.synthetic.main.home_result_grid.view.background_card
|
|
||||||
import kotlinx.android.synthetic.main.home_result_grid_expanded.view.*
|
class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(view) {
|
||||||
|
/*private fun recursive(view : View) : Boolean {
|
||||||
|
if (view.isFocused) {
|
||||||
|
println("VIEW: $view | id=${view.id}")
|
||||||
|
}
|
||||||
|
return (view as? ViewGroup)?.children?.any { recursive(it) } ?: false
|
||||||
|
}*/
|
||||||
|
|
||||||
|
// very shitty that we cant store the state when the view clears,
|
||||||
|
// but this is because the focus clears before the view is removed
|
||||||
|
// so we have to manually store it
|
||||||
|
var wasFocused: Boolean = false
|
||||||
|
override fun save(): Boolean = wasFocused
|
||||||
|
override fun restore(state: Boolean) {
|
||||||
|
if (state) {
|
||||||
|
wasFocused = false
|
||||||
|
// only refocus if tv
|
||||||
|
if(isLayout(TV)) {
|
||||||
|
itemView.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class HomeChildItemAdapter(
|
class HomeChildItemAdapter(
|
||||||
val cardList: MutableList<SearchResponse>,
|
fragment: Fragment,
|
||||||
private val overrideLayout: Int? = null,
|
id: Int,
|
||||||
private val nextFocusUp: Int? = null,
|
private val nextFocusUp: Int? = null,
|
||||||
private val nextFocusDown: Int? = null,
|
private val nextFocusDown: Int? = null,
|
||||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||||
) :
|
) :
|
||||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
BaseAdapter<SearchResponse, Boolean>(fragment, id) {
|
||||||
var isHorizontal: Boolean = false
|
var isHorizontal: Boolean = false
|
||||||
var hasNext: Boolean = false
|
var hasNext: Boolean = false
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
|
||||||
val layout = overrideLayout
|
val expanded = parent.context.isBottomLayout()
|
||||||
?: if (parent.context.IsBottomLayout()) R.layout.home_result_grid_expanded else R.layout.home_result_grid
|
/* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid
|
||||||
|
|
||||||
return CardViewHolder(
|
val root = LayoutInflater.from(parent.context).inflate(layout, parent, false)
|
||||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
val binding = HomeResultGridBinding.bind(root)*/
|
||||||
clickCallback,
|
|
||||||
itemCount,
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
val binding = if (expanded) HomeResultGridExpandedBinding.inflate(
|
||||||
|
inflater,
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
) else HomeResultGridBinding.inflate(inflater, parent, false)
|
||||||
|
return HomeScrollViewHolderState(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindContent(
|
||||||
|
holder: ViewHolderState<Boolean>,
|
||||||
|
item: SearchResponse,
|
||||||
|
position: Int
|
||||||
|
) {
|
||||||
|
when (val binding = holder.view) {
|
||||||
|
is HomeResultGridBinding -> {
|
||||||
|
binding.backgroundCard.apply {
|
||||||
|
val min = 114.toPx
|
||||||
|
val max = 180.toPx
|
||||||
|
|
||||||
|
layoutParams =
|
||||||
|
layoutParams.apply {
|
||||||
|
width = if (!isHorizontal) {
|
||||||
|
min
|
||||||
|
} else {
|
||||||
|
max
|
||||||
|
}
|
||||||
|
height = if (!isHorizontal) {
|
||||||
|
max
|
||||||
|
} else {
|
||||||
|
min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is HomeResultGridExpandedBinding -> {
|
||||||
|
binding.backgroundCard.apply {
|
||||||
|
val min = 114.toPx
|
||||||
|
val max = 180.toPx
|
||||||
|
|
||||||
|
layoutParams =
|
||||||
|
layoutParams.apply {
|
||||||
|
width = if (!isHorizontal) {
|
||||||
|
min
|
||||||
|
} else {
|
||||||
|
max
|
||||||
|
}
|
||||||
|
height = if (!isHorizontal) {
|
||||||
|
max
|
||||||
|
} else {
|
||||||
|
min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position == 0) { // to fix tv
|
||||||
|
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchResultBuilder.bind(
|
||||||
|
clickCallback = { click ->
|
||||||
|
// ok, so here we hijack the callback to fix the focus
|
||||||
|
when (click.action) {
|
||||||
|
SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true
|
||||||
|
}
|
||||||
|
clickCallback(click)
|
||||||
|
},
|
||||||
|
item,
|
||||||
|
position,
|
||||||
|
holder.itemView,
|
||||||
nextFocusUp,
|
nextFocusUp,
|
||||||
nextFocusDown,
|
nextFocusDown
|
||||||
isHorizontal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is CardViewHolder -> {
|
|
||||||
holder.itemCount = itemCount // i know ugly af
|
|
||||||
holder.bind(cardList[position], position)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return cardList.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemId(position: Int): Long {
|
|
||||||
return (cardList[position].id ?: position).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateList(newList: List<SearchResponse>) {
|
|
||||||
val diffResult = DiffUtil.calculateDiff(
|
|
||||||
HomeChildDiffCallback(this.cardList, newList)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cardList.clear()
|
holder.itemView.tag = position
|
||||||
cardList.addAll(newList)
|
|
||||||
|
|
||||||
diffResult.dispatchUpdatesTo(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CardViewHolder
|
|
||||||
constructor(
|
|
||||||
itemView: View,
|
|
||||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
|
||||||
var itemCount: Int,
|
|
||||||
private val nextFocusUp: Int? = null,
|
|
||||||
private val nextFocusDown: Int? = null,
|
|
||||||
private val isHorizontal: Boolean = false
|
|
||||||
) :
|
|
||||||
RecyclerView.ViewHolder(itemView) {
|
|
||||||
|
|
||||||
fun bind(card: SearchResponse, position: Int) {
|
|
||||||
|
|
||||||
// TV focus fixing
|
|
||||||
val nextFocusBehavior = when (position) {
|
|
||||||
0 -> true
|
|
||||||
itemCount - 1 -> false
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
(itemView.image_holder ?: itemView.background_card)?.apply {
|
|
||||||
val min = 114.toPx
|
|
||||||
val max = 180.toPx
|
|
||||||
|
|
||||||
layoutParams =
|
|
||||||
layoutParams.apply {
|
|
||||||
width = if (!isHorizontal) {
|
|
||||||
min
|
|
||||||
} else {
|
|
||||||
max
|
|
||||||
}
|
|
||||||
height = if (!isHorizontal) {
|
|
||||||
max
|
|
||||||
} else {
|
|
||||||
min
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
SearchResultBuilder.bind(
|
|
||||||
clickCallback,
|
|
||||||
card,
|
|
||||||
position,
|
|
||||||
itemView,
|
|
||||||
nextFocusBehavior,
|
|
||||||
nextFocusUp,
|
|
||||||
nextFocusDown
|
|
||||||
)
|
|
||||||
itemView.tag = position
|
|
||||||
|
|
||||||
if (position == 0) { // to fix tv
|
|
||||||
itemView.background_card?.nextFocusLeftId = R.id.nav_rail_view
|
|
||||||
}
|
|
||||||
//val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f)
|
|
||||||
//ani.fillAfter = true
|
|
||||||
//ani.duration = 200
|
|
||||||
//itemView.startAnimation(ani)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeChildDiffCallback(
|
|
||||||
private val oldList: List<SearchResponse>,
|
|
||||||
private val newList: List<SearchResponse>
|
|
||||||
) :
|
|
||||||
DiffUtil.Callback() {
|
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
oldList[oldItemPosition].name == newList[newItemPosition].name
|
|
||||||
|
|
||||||
override fun getOldListSize() = oldList.size
|
|
||||||
|
|
||||||
override fun getNewListSize() = newList.size
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
oldList[oldItemPosition] == newList[newItemPosition] && oldItemPosition < oldList.size - 1 // always update the last item
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,6 @@ import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
|
@ -18,76 +17,49 @@ import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.*
|
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.chip.ChipGroup
|
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
|
import com.lagradost.cloudstream3.databinding.TvtypesChipsBinding
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
|
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
|
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
|
||||||
import com.lagradost.cloudstream3.ui.search.*
|
import com.lagradost.cloudstream3.ui.search.*
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
|
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.ownHide
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.ownShow
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
|
||||||
import kotlinx.android.synthetic.main.activity_main_tv.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_api_fab
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_change_api_loading
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_loading
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_error
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_shimmer
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_loading_statusbar
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_master_recycler
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_reload_connection_open_in_browser
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.result_error_text
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_tv.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_result.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_search.*
|
|
||||||
import kotlinx.android.synthetic.main.home_episodes_expanded.*
|
|
||||||
import kotlinx.android.synthetic.main.tvtypes_chips.*
|
|
||||||
import kotlinx.android.synthetic.main.tvtypes_chips.view.*
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
|
||||||
const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list"
|
|
||||||
const val HOME_PREF_HOMEPAGE = "home_pref_homepage"
|
|
||||||
|
|
||||||
class HomeFragment : Fragment() {
|
class HomeFragment : Fragment() {
|
||||||
companion object {
|
companion object {
|
||||||
val configEvent = Event<Int>()
|
val configEvent = Event<Int>()
|
||||||
|
|
@ -125,22 +97,26 @@ class HomeFragment : Fragment() {
|
||||||
expand: HomeViewModel.ExpandableHomepageList,
|
expand: HomeViewModel.ExpandableHomepageList,
|
||||||
deleteCallback: (() -> Unit)? = null,
|
deleteCallback: (() -> Unit)? = null,
|
||||||
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null,
|
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null,
|
||||||
dismissCallback : (() -> Unit),
|
dismissCallback: (() -> Unit),
|
||||||
): BottomSheetDialog {
|
): BottomSheetDialog {
|
||||||
val context = this
|
val context = this
|
||||||
val bottomSheetDialogBuilder = BottomSheetDialog(context)
|
val bottomSheetDialogBuilder = BottomSheetDialog(context)
|
||||||
|
val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate(
|
||||||
bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded)
|
bottomSheetDialogBuilder.layoutInflater,
|
||||||
val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
bottomSheetDialogBuilder.setContentView(binding.root)
|
||||||
|
//val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
|
||||||
|
|
||||||
//title.findViewTreeLifecycleOwner().lifecycle.addObserver()
|
//title.findViewTreeLifecycleOwner().lifecycle.addObserver()
|
||||||
|
|
||||||
val item = expand.list
|
val item = expand.list
|
||||||
title.text = item.name
|
binding.homeExpandedText.text = item.name
|
||||||
val recycle =
|
// val recycle =
|
||||||
bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!!
|
// bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!!
|
||||||
val titleHolder =
|
//val titleHolder =
|
||||||
bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
|
// bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
|
||||||
|
|
||||||
// main {
|
// main {
|
||||||
//(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply {
|
//(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply {
|
||||||
|
|
@ -159,10 +135,10 @@ class HomeFragment : Fragment() {
|
||||||
// })
|
// })
|
||||||
//}
|
//}
|
||||||
// }
|
// }
|
||||||
val delete = bottomSheetDialogBuilder.home_expanded_delete
|
//val delete = bottomSheetDialogBuilder.home_expanded_delete
|
||||||
delete.isGone = deleteCallback == null
|
binding.homeExpandedDelete.isGone = deleteCallback == null
|
||||||
if (deleteCallback != null) {
|
if (deleteCallback != null) {
|
||||||
delete.setOnClickListener {
|
binding.homeExpandedDelete.setOnClickListener {
|
||||||
try {
|
try {
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(context)
|
val builder: AlertDialog.Builder = AlertDialog.Builder(context)
|
||||||
val dialogClickListener =
|
val dialogClickListener =
|
||||||
|
|
@ -172,6 +148,7 @@ class HomeFragment : Fragment() {
|
||||||
deleteCallback.invoke()
|
deleteCallback.invoke()
|
||||||
bottomSheetDialogBuilder.dismissSafe(this)
|
bottomSheetDialogBuilder.dismissSafe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogInterface.BUTTON_NEGATIVE -> {}
|
DialogInterface.BUTTON_NEGATIVE -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,26 +168,27 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding.homeExpandedDragDown.setOnClickListener {
|
||||||
titleHolder.setOnClickListener {
|
|
||||||
bottomSheetDialogBuilder.dismissSafe(this)
|
bottomSheetDialogBuilder.dismissSafe(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Span settings
|
// Span settings
|
||||||
recycle.spanCount = currentSpan
|
binding.homeExpandedRecycler.spanCount = currentSpan
|
||||||
|
|
||||||
recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback ->
|
binding.homeExpandedRecycler.adapter =
|
||||||
handleSearchClickCallback(this, callback)
|
SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
|
||||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
handleSearchClickCallback(callback)
|
||||||
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
||||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
||||||
|
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
hasNext = expand.hasNext
|
||||||
}
|
}
|
||||||
}.apply {
|
|
||||||
hasNext = expand.hasNext
|
|
||||||
}
|
|
||||||
|
|
||||||
recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.homeExpandedRecycler.addOnScrollListener(object :
|
||||||
|
RecyclerView.OnScrollListener() {
|
||||||
var expandCount = 0
|
var expandCount = 0
|
||||||
val name = expand.list.name
|
val name = expand.list.name
|
||||||
|
|
||||||
|
|
@ -238,7 +216,7 @@ class HomeFragment : Fragment() {
|
||||||
})
|
})
|
||||||
|
|
||||||
val spanListener = { span: Int ->
|
val spanListener = { span: Int ->
|
||||||
recycle.spanCount = span
|
binding.homeExpandedRecycler.spanCount = span
|
||||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +233,7 @@ class HomeFragment : Fragment() {
|
||||||
return bottomSheetDialogBuilder
|
return bottomSheetDialogBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPairList(
|
private fun getPairList(
|
||||||
anime: Chip?,
|
anime: Chip?,
|
||||||
cartoons: Chip?,
|
cartoons: Chip?,
|
||||||
tvs: Chip?,
|
tvs: Chip?,
|
||||||
|
|
@ -280,19 +258,19 @@ class HomeFragment : Fragment() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPairList(header: ChipGroup) = getPairList(
|
private fun getPairList(header: TvtypesChipsBinding) = getPairList(
|
||||||
header.home_select_anime,
|
header.homeSelectAnime,
|
||||||
header.home_select_cartoons,
|
header.homeSelectCartoons,
|
||||||
header.home_select_tv_series,
|
header.homeSelectTvSeries,
|
||||||
header.home_select_documentaries,
|
header.homeSelectDocumentaries,
|
||||||
header.home_select_movies,
|
header.homeSelectMovies,
|
||||||
header.home_select_asian,
|
header.homeSelectAsian,
|
||||||
header.home_select_livestreams,
|
header.homeSelectLivestreams,
|
||||||
header.home_select_nsfw,
|
header.homeSelectNsfw,
|
||||||
header.home_select_others
|
header.homeSelectOthers
|
||||||
)
|
)
|
||||||
|
|
||||||
fun validateChips(header: ChipGroup?, validTypes: List<TvType>) {
|
fun validateChips(header: TvtypesChipsBinding?, validTypes: List<TvType>) {
|
||||||
if (header == null) return
|
if (header == null) return
|
||||||
val pairList = getPairList(header)
|
val pairList = getPairList(header)
|
||||||
for ((button, types) in pairList) {
|
for ((button, types) in pairList) {
|
||||||
|
|
@ -301,7 +279,7 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateChips(header: ChipGroup?, selectedTypes: List<TvType>) {
|
fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List<TvType>) {
|
||||||
if (header == null) return
|
if (header == null) return
|
||||||
val pairList = getPairList(header)
|
val pairList = getPairList(header)
|
||||||
for ((button, types) in pairList) {
|
for ((button, types) in pairList) {
|
||||||
|
|
@ -311,10 +289,21 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bindChips(
|
fun bindChips(
|
||||||
header: ChipGroup?,
|
header: TvtypesChipsBinding?,
|
||||||
selectedTypes: List<TvType>,
|
selectedTypes: List<TvType>,
|
||||||
validTypes: List<TvType>,
|
validTypes: List<TvType>,
|
||||||
callback: (List<TvType>) -> Unit
|
callback: (List<TvType>) -> Unit
|
||||||
|
) {
|
||||||
|
bindChips(header, selectedTypes, validTypes, callback, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bindChips(
|
||||||
|
header: TvtypesChipsBinding?,
|
||||||
|
selectedTypes: List<TvType>,
|
||||||
|
validTypes: List<TvType>,
|
||||||
|
callback: (List<TvType>) -> Unit,
|
||||||
|
nextFocusDown: Int?,
|
||||||
|
nextFocusUp: Int?
|
||||||
) {
|
) {
|
||||||
if (header == null) return
|
if (header == null) return
|
||||||
val pairList = getPairList(header)
|
val pairList = getPairList(header)
|
||||||
|
|
@ -322,6 +311,17 @@ class HomeFragment : Fragment() {
|
||||||
val isValid = validTypes.any { types.contains(it) }
|
val isValid = validTypes.any { types.contains(it) }
|
||||||
button?.isVisible = isValid
|
button?.isVisible = isValid
|
||||||
button?.isChecked = isValid && selectedTypes.any { types.contains(it) }
|
button?.isChecked = isValid && selectedTypes.any { types.contains(it) }
|
||||||
|
button?.isFocusable = true
|
||||||
|
if (isLayout(TV)) {
|
||||||
|
button?.isFocusableInTouchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextFocusDown != null)
|
||||||
|
button?.nextFocusDownId = nextFocusDown
|
||||||
|
|
||||||
|
if (nextFocusUp != null)
|
||||||
|
button?.nextFocusUpId = nextFocusUp
|
||||||
|
|
||||||
button?.setOnCheckedChangeListener { _, _ ->
|
button?.setOnCheckedChangeListener { _, _ ->
|
||||||
val list = ArrayList<TvType>()
|
val list = ArrayList<TvType>()
|
||||||
for ((sbutton, vvalidTypes) in pairList) {
|
for ((sbutton, vvalidTypes) in pairList) {
|
||||||
|
|
@ -344,7 +344,13 @@ class HomeFragment : Fragment() {
|
||||||
BottomSheetDialog(this)
|
BottomSheetDialog(this)
|
||||||
|
|
||||||
builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
builder.setContentView(R.layout.home_select_mainpage)
|
val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate(
|
||||||
|
builder.layoutInflater,
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.setContentView(binding.root)
|
||||||
builder.show()
|
builder.show()
|
||||||
builder.let { dialog ->
|
builder.let { dialog ->
|
||||||
val isMultiLang = getApiProviderLangSettings().let { set ->
|
val isMultiLang = getApiProviderLangSettings().let { set ->
|
||||||
|
|
@ -355,19 +361,13 @@ class HomeFragment : Fragment() {
|
||||||
var currentApiName = selectedApiName
|
var currentApiName = selectedApiName
|
||||||
|
|
||||||
var currentValidApis: MutableList<MainAPI> = mutableListOf()
|
var currentValidApis: MutableList<MainAPI> = mutableListOf()
|
||||||
val preSelectedTypes = this.getKey<List<String>>(HOME_PREF_HOMEPAGE)
|
val preSelectedTypes = DataStoreHelper.homePreference.toMutableList()
|
||||||
?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } }
|
|
||||||
?.toMutableList()
|
|
||||||
?: mutableListOf(TvType.Movie, TvType.TvSeries)
|
|
||||||
|
|
||||||
val cancelBtt = dialog.findViewById<MaterialButton>(R.id.cancel_btt)
|
binding.cancelBtt.setOnClickListener {
|
||||||
val applyBtt = dialog.findViewById<MaterialButton>(R.id.apply_btt)
|
|
||||||
|
|
||||||
cancelBtt?.setOnClickListener {
|
|
||||||
dialog.dismissSafe()
|
dialog.dismissSafe()
|
||||||
}
|
}
|
||||||
|
|
||||||
applyBtt?.setOnClickListener {
|
binding.applyBtt.setOnClickListener {
|
||||||
if (currentApiName != selectedApiName) {
|
if (currentApiName != selectedApiName) {
|
||||||
currentApiName?.let(callback)
|
currentApiName?.let(callback)
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +389,7 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateList() {
|
fun updateList() {
|
||||||
this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes)
|
DataStoreHelper.homePreference = preSelectedTypes
|
||||||
|
|
||||||
arrayAdapter.clear()
|
arrayAdapter.clear()
|
||||||
currentValidApis = validAPIs.filter { api ->
|
currentValidApis = validAPIs.filter { api ->
|
||||||
|
|
@ -408,7 +408,7 @@ class HomeFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
bindChips(
|
bindChips(
|
||||||
dialog.home_select_group,
|
binding.tvtypesChipsScroll.tvtypesChips,
|
||||||
preSelectedTypes,
|
preSelectedTypes,
|
||||||
validAPIs.flatMap { it.supportedTypes }.distinct()
|
validAPIs.flatMap { it.supportedTypes }.distinct()
|
||||||
) { list ->
|
) { list ->
|
||||||
|
|
@ -423,6 +423,9 @@ class HomeFragment : Fragment() {
|
||||||
|
|
||||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
var binding: FragmentHomeBinding? = null
|
||||||
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
|
|
@ -430,14 +433,26 @@ class HomeFragment : Fragment() {
|
||||||
): View? {
|
): View? {
|
||||||
//homeViewModel =
|
//homeViewModel =
|
||||||
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||||
|
|
||||||
bottomSheetDialog?.ownShow()
|
bottomSheetDialog?.ownShow()
|
||||||
val layout =
|
val layout =
|
||||||
if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home
|
if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home
|
||||||
return inflater.inflate(layout, container, false)
|
val root = inflater.inflate(layout, container, false)
|
||||||
|
binding = try {
|
||||||
|
FragmentHomeBinding.bind(root)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
|
||||||
|
logError(t)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|
||||||
bottomSheetDialog?.ownHide()
|
bottomSheetDialog?.ownHide()
|
||||||
|
binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,7 +465,7 @@ class HomeFragment : Fragment() {
|
||||||
|
|
||||||
private val apiChangeClickListener = View.OnClickListener { view ->
|
private val apiChangeClickListener = View.OnClickListener { view ->
|
||||||
view.context.selectHomepage(currentApiName) { api ->
|
view.context.selectHomepage(currentApiName) { api ->
|
||||||
homeViewModel.loadAndCancel(api)
|
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
||||||
}
|
}
|
||||||
/*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf()
|
/*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf()
|
||||||
|
|
||||||
|
|
@ -467,196 +482,148 @@ class HomeFragment : Fragment() {
|
||||||
fixGrid()
|
fixGrid()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bookmarksUpdated(_data : Boolean) {
|
|
||||||
reloadStored()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
reloadStored()
|
|
||||||
bookmarksUpdatedEvent += ::bookmarksUpdated
|
|
||||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
|
||||||
mainPluginsLoadedEvent += ::afterMainPluginsLoaded
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
bookmarksUpdatedEvent -= ::bookmarksUpdated
|
|
||||||
afterPluginsLoadedEvent -= ::afterPluginsLoaded
|
|
||||||
mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
|
|
||||||
super.onStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reloadStored() {
|
|
||||||
homeViewModel.loadResumeWatching()
|
|
||||||
val list = EnumSet.noneOf(WatchType::class.java)
|
|
||||||
getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let {
|
|
||||||
list.addAll(it)
|
|
||||||
}
|
|
||||||
homeViewModel.loadStoredData(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun afterMainPluginsLoaded(unused: Boolean = false) {
|
|
||||||
loadHomePage(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
|
||||||
loadHomePage(forceReload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadHomePage(forceReload: Boolean) {
|
|
||||||
val apiName = context?.getKey<String>(USER_SELECTED_HOMEPAGE_API)
|
|
||||||
|
|
||||||
if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) {
|
|
||||||
//println("Caught home: " + homeViewModel.apiName.value + " at " + apiName)
|
|
||||||
homeViewModel.loadAndCancel(apiName, forceReload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun homeHandleSearch(callback: SearchClickCallback) {
|
|
||||||
if (callback.action == SEARCH_ACTION_FOCUSED) {
|
|
||||||
//focusCallback(callback.card)
|
|
||||||
} else {
|
|
||||||
handleSearchClickCallback(activity, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentApiName: String? = null
|
private var currentApiName: String? = null
|
||||||
private var toggleRandomButton = false
|
private var toggleRandomButton = false
|
||||||
|
|
||||||
private var bottomSheetDialog: BottomSheetDialog? = null
|
private var bottomSheetDialog: BottomSheetDialog? = null
|
||||||
|
|
||||||
|
// https://github.com/vivchar/RendererRecyclerViewAdapter/blob/185251ee9d94fb6eb3e063b00d646b745186c365/example/src/main/java/com/github/vivchar/example/pages/github/GithubFragment.kt#L32
|
||||||
|
// cry about it, but this is android we are talking about, we cant do the most simple shit without making a global variable
|
||||||
|
private var instanceState: Bundle = Bundle()
|
||||||
|
private var homeMasterAdapter: HomeParentItemAdapterPreview? = null
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
fixGrid()
|
fixGrid()
|
||||||
|
|
||||||
home_change_api_loading?.setOnClickListener(apiChangeClickListener)
|
binding?.apply {
|
||||||
home_api_fab?.setOnClickListener(apiChangeClickListener)
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
home_random?.setOnClickListener {
|
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||||
if (listHomepageItems.isNotEmpty()) {
|
homeApiFab.setOnClickListener(apiChangeClickListener)
|
||||||
activity.loadSearchResult(listHomepageItems.random())
|
homeChangeApi.setOnClickListener(apiChangeClickListener)
|
||||||
|
homeSwitchAccount.setOnClickListener {
|
||||||
|
activity?.showAccountSelectLinear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
homeRandom.setOnClickListener {
|
||||||
|
if (listHomepageItems.isNotEmpty()) {
|
||||||
|
activity.loadSearchResult(listHomepageItems.random())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
homeMasterAdapter = HomeParentItemAdapterPreview(
|
||||||
|
fragment = this@HomeFragment,
|
||||||
|
homeViewModel,
|
||||||
|
)
|
||||||
|
homeMasterRecycler.adapter = homeMasterAdapter
|
||||||
|
//fixPaddingStatusbar(homeLoadingStatusbar)
|
||||||
|
|
||||||
|
homeApiFab.isVisible = isLayout(PHONE)
|
||||||
|
|
||||||
|
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
if (dy > 0) { //check for scroll down
|
||||||
|
homeApiFab.shrink() // hide
|
||||||
|
homeRandom.shrink()
|
||||||
|
} else if (dy < -5) {
|
||||||
|
if (isLayout(PHONE)) {
|
||||||
|
homeApiFab.extend() // show
|
||||||
|
homeRandom.extend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onScrolled(recyclerView, dx, dy)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//Load value for toggling Random button. Hide at startup
|
//Load value for toggling Random button. Hide at startup
|
||||||
context?.let {
|
context?.let {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||||
toggleRandomButton =
|
toggleRandomButton =
|
||||||
settingsManager.getBoolean(getString(R.string.random_button_key), false)
|
settingsManager.getBoolean(
|
||||||
home_random?.visibility = View.GONE
|
getString(R.string.random_button_key),
|
||||||
}
|
false
|
||||||
|
) && isLayout(PHONE)
|
||||||
observe(homeViewModel.preview) { preview ->
|
binding?.homeRandom?.visibility = View.GONE
|
||||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData(
|
|
||||||
preview
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(homeViewModel.apiName) { apiName ->
|
observe(homeViewModel.apiName) { apiName ->
|
||||||
currentApiName = apiName
|
currentApiName = apiName
|
||||||
home_api_fab?.text = apiName
|
binding?.homeApiFab?.text = apiName
|
||||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName(
|
binding?.homeChangeApi?.text = apiName
|
||||||
apiName
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observe(homeViewModel.page) { data ->
|
observe(homeViewModel.page) { data ->
|
||||||
when (data) {
|
binding?.apply {
|
||||||
is Resource.Success -> {
|
when (data) {
|
||||||
home_loading_shimmer?.stopShimmer()
|
is Resource.Success -> {
|
||||||
|
homeLoadingShimmer.stopShimmer()
|
||||||
|
|
||||||
val d = data.value
|
val d = data.value
|
||||||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||||
listHomepageItems.clear()
|
listHomepageItems.clear()
|
||||||
|
|
||||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(
|
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
|
||||||
d.values.toMutableList(),
|
it.copy(
|
||||||
home_master_recycler
|
list = it.list.copy(list = it.list.list.toMutableList())
|
||||||
)
|
|
||||||
|
|
||||||
home_loading?.isVisible = false
|
|
||||||
home_loading_error?.isVisible = false
|
|
||||||
home_master_recycler?.isVisible = true
|
|
||||||
//home_loaded?.isVisible = true
|
|
||||||
if (toggleRandomButton) {
|
|
||||||
//Flatten list
|
|
||||||
d.values.forEach { dlist ->
|
|
||||||
mutableListOfResponse.addAll(dlist.list.list)
|
|
||||||
}
|
|
||||||
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
|
|
||||||
home_random?.isVisible = listHomepageItems.isNotEmpty()
|
|
||||||
} else {
|
|
||||||
home_random?.isGone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is Resource.Failure -> {
|
|
||||||
home_loading_shimmer?.stopShimmer()
|
|
||||||
|
|
||||||
result_error_text.text = data.errorString
|
|
||||||
|
|
||||||
home_reload_connectionerror.setOnClickListener(apiChangeClickListener)
|
|
||||||
|
|
||||||
home_reload_connection_open_in_browser.setOnClickListener { view ->
|
|
||||||
val validAPIs = apis//.filter { api -> api.hasMainPage }
|
|
||||||
|
|
||||||
view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api ->
|
|
||||||
Pair(
|
|
||||||
index,
|
|
||||||
api.name
|
|
||||||
)
|
)
|
||||||
}) {
|
}.toMutableList())
|
||||||
try {
|
|
||||||
val i = Intent(Intent.ACTION_VIEW)
|
homeLoading.isVisible = false
|
||||||
i.data = Uri.parse(validAPIs[itemId].mainUrl)
|
homeLoadingError.isVisible = false
|
||||||
startActivity(i)
|
homeMasterRecycler.isVisible = true
|
||||||
} catch (e: Exception) {
|
//home_loaded?.isVisible = true
|
||||||
logError(e)
|
if (toggleRandomButton) {
|
||||||
|
//Flatten list
|
||||||
|
d.values.forEach { dlist ->
|
||||||
|
mutableListOfResponse.addAll(dlist.list.list)
|
||||||
|
}
|
||||||
|
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
|
||||||
|
|
||||||
|
homeRandom.isVisible = listHomepageItems.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
homeRandom.isGone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Resource.Failure -> {
|
||||||
|
homeLoadingShimmer.stopShimmer()
|
||||||
|
resultErrorText.text = data.errorString
|
||||||
|
homeReloadConnectionerror.setOnClickListener(apiChangeClickListener)
|
||||||
|
homeReloadConnectionOpenInBrowser.setOnClickListener { view ->
|
||||||
|
val validAPIs = apis//.filter { api -> api.hasMainPage }
|
||||||
|
|
||||||
|
view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api ->
|
||||||
|
Pair(
|
||||||
|
index,
|
||||||
|
api.name
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
val i = Intent(Intent.ACTION_VIEW)
|
||||||
|
i.data = Uri.parse(validAPIs[itemId].mainUrl)
|
||||||
|
startActivity(i)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
homeLoading.isVisible = false
|
||||||
|
homeLoadingError.isVisible = true
|
||||||
|
homeMasterRecycler.isVisible = false
|
||||||
|
//home_loaded?.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
home_loading?.isVisible = false
|
is Resource.Loading -> {
|
||||||
home_loading_error?.isVisible = true
|
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
|
||||||
home_master_recycler?.isVisible = false
|
homeLoadingShimmer.startShimmer()
|
||||||
//home_loaded?.isVisible = false
|
homeLoading.isVisible = true
|
||||||
}
|
homeLoadingError.isVisible = false
|
||||||
is Resource.Loading -> {
|
homeMasterRecycler.isVisible = false
|
||||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(listOf())
|
//home_loaded?.isVisible = false
|
||||||
home_loading_shimmer?.startShimmer()
|
|
||||||
home_loading?.isVisible = true
|
|
||||||
home_loading_error?.isVisible = false
|
|
||||||
home_master_recycler?.isVisible = false
|
|
||||||
//home_loaded?.isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes ->
|
|
||||||
context?.setKey(
|
|
||||||
HOME_BOOKMARK_VALUE_LIST,
|
|
||||||
availableWatchStatusTypes.first.map { it.internalId }.toIntArray()
|
|
||||||
)
|
|
||||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setAvailableWatchStatusTypes(
|
|
||||||
availableWatchStatusTypes
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(homeViewModel.bookmarks) { data ->
|
|
||||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setBookmarkData(
|
|
||||||
data
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(homeViewModel.resumeWatching) { resumeWatching ->
|
|
||||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setResumeWatchingData(
|
|
||||||
resumeWatching
|
|
||||||
)
|
|
||||||
if (isTrueTvSettings()) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
ioSafe {
|
|
||||||
activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -665,72 +632,37 @@ class HomeFragment : Fragment() {
|
||||||
|
|
||||||
//context?.fixPaddingStatusbarView(home_statusbar)
|
//context?.fixPaddingStatusbarView(home_statusbar)
|
||||||
//context?.fixPaddingStatusbar(home_padding)
|
//context?.fixPaddingStatusbar(home_padding)
|
||||||
context?.fixPaddingStatusbar(home_loading_statusbar)
|
|
||||||
|
|
||||||
home_master_recycler?.adapter =
|
observeNullable(homeViewModel.popup) { item ->
|
||||||
HomeParentItemAdapterPreview(mutableListOf(), { callback ->
|
if (item == null) {
|
||||||
homeHandleSearch(callback)
|
bottomSheetDialog?.dismissSafe()
|
||||||
}, { item ->
|
bottomSheetDialog = null
|
||||||
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = {
|
return@observeNullable
|
||||||
homeViewModel.expandAndReturn(it)
|
|
||||||
}, dismissCallback = {
|
|
||||||
bottomSheetDialog = null
|
|
||||||
})
|
|
||||||
}, { name ->
|
|
||||||
homeViewModel.expand(name)
|
|
||||||
}, { load ->
|
|
||||||
activity?.loadResult(load.response.url, load.response.apiName, load.action)
|
|
||||||
}, {
|
|
||||||
homeViewModel.loadMoreHomeScrollResponses()
|
|
||||||
}, {
|
|
||||||
apiChangeClickListener.onClick(it)
|
|
||||||
}, reloadStored = {
|
|
||||||
reloadStored()
|
|
||||||
}, loadStoredData = {
|
|
||||||
homeViewModel.loadStoredData(it)
|
|
||||||
}, { (isQuickSearch, text) ->
|
|
||||||
if (!isQuickSearch) {
|
|
||||||
QuickSearchFragment.pushSearch(
|
|
||||||
activity,
|
|
||||||
text,
|
|
||||||
currentApiName?.let { arrayOf(it) })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
reloadStored()
|
|
||||||
loadHomePage(false)
|
|
||||||
home_master_recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
if (dy > 0) { //check for scroll down
|
|
||||||
home_api_fab?.shrink() // hide
|
|
||||||
home_random?.shrink()
|
|
||||||
} else if (dy < -5) {
|
|
||||||
if (!isTvSettings()) {
|
|
||||||
home_api_fab?.extend() // show
|
|
||||||
home_random?.extend()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onScrolled(recyclerView, dx, dy)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
// don't recreate
|
||||||
|
if (bottomSheetDialog != null) {
|
||||||
|
return@observeNullable
|
||||||
|
}
|
||||||
|
|
||||||
|
val (items, delete) = item
|
||||||
|
|
||||||
|
bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = {
|
||||||
|
homeViewModel.expandAndReturn(it)
|
||||||
|
}, dismissCallback = {
|
||||||
|
homeViewModel.popup(null)
|
||||||
|
bottomSheetDialog = null
|
||||||
|
}, deleteCallback = delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
homeViewModel.reloadStored()
|
||||||
|
homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false)
|
||||||
|
//loadHomePage(false)
|
||||||
|
|
||||||
// nice profile pic on homepage
|
// nice profile pic on homepage
|
||||||
//home_profile_picture_holder?.isVisible = false
|
//home_profile_picture_holder?.isVisible = false
|
||||||
// just in case
|
// just in case
|
||||||
if (isTvSettings()) {
|
|
||||||
home_api_fab?.isVisible = false
|
|
||||||
if (isTrueTvSettings()) {
|
|
||||||
home_change_api_loading?.isVisible = true
|
|
||||||
home_change_api_loading?.isFocusable = true
|
|
||||||
home_change_api_loading?.isFocusableInTouchMode = true
|
|
||||||
}
|
|
||||||
// home_bookmark_select?.isFocusable = true
|
|
||||||
// home_bookmark_select?.isFocusableInTouchMode = true
|
|
||||||
} else {
|
|
||||||
home_api_fab?.isVisible = true
|
|
||||||
home_change_api_loading?.isVisible = false
|
|
||||||
}
|
|
||||||
//TODO READD THIS
|
//TODO READD THIS
|
||||||
/*for (syncApi in OAuth2Apis) {
|
/*for (syncApi in OAuth2Apis) {
|
||||||
val login = syncApi.loginInfo()
|
val login = syncApi.loginInfo()
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,30 @@
|
||||||
package com.lagradost.cloudstream3.ui.home
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import androidx.fragment.app.Fragment
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.ListUpdateCallback
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.transition.ChangeBounds
|
import androidx.viewbinding.ViewBinding
|
||||||
import androidx.transition.TransitionManager
|
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import com.google.android.material.chip.ChipDrawable
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.getId
|
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
|
||||||
import com.lagradost.cloudstream3.HomePageList
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.SearchResponse
|
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.BaseAdapter
|
||||||
import com.lagradost.cloudstream3.ui.result.LinearListLayout
|
import com.lagradost.cloudstream3.ui.BaseDiffCallback
|
||||||
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
|
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
|
||||||
import kotlinx.android.synthetic.main.activity_main_tv.*
|
|
||||||
import kotlinx.android.synthetic.main.activity_main_tv.view.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home.view.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager
|
|
||||||
import kotlinx.android.synthetic.main.homepage_parent.view.*
|
|
||||||
|
|
||||||
class LoadClickCallback(
|
class LoadClickCallback(
|
||||||
val action: Int = 0,
|
val action: Int = 0,
|
||||||
|
|
@ -56,174 +34,89 @@ class LoadClickCallback(
|
||||||
)
|
)
|
||||||
|
|
||||||
open class ParentItemAdapter(
|
open class ParentItemAdapter(
|
||||||
private var items: MutableList<HomeViewModel.ExpandableHomepageList>,
|
open val fragment: Fragment,
|
||||||
|
id: Int,
|
||||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||||
private val expandCallback: ((String) -> Unit)? = null,
|
private val expandCallback: ((String) -> Unit)? = null,
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
fragment,
|
||||||
return ParentViewHolder(
|
id,
|
||||||
LayoutInflater.from(parent.context).inflate(
|
diffCallback = BaseDiffCallback(
|
||||||
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
|
itemSame = { a, b -> a.list.name == b.list.name },
|
||||||
parent,
|
contentSame = { a, b ->
|
||||||
false
|
a.list.list == b.list.list
|
||||||
),
|
|
||||||
clickCallback,
|
|
||||||
moreInfoClickCallback,
|
|
||||||
expandCallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is ParentViewHolder -> {
|
|
||||||
holder.bind(items[position])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return items.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemId(position: Int): Long {
|
|
||||||
return items[position].list.name.hashCode().toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmName("updateListHomePageList")
|
|
||||||
fun updateList(newList: List<HomePageList>) {
|
|
||||||
updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
|
|
||||||
.toMutableList())
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmName("updateListExpandableHomepageList")
|
|
||||||
fun updateList(
|
|
||||||
newList: MutableList<HomeViewModel.ExpandableHomepageList>,
|
|
||||||
recyclerView: RecyclerView? = null
|
|
||||||
) {
|
|
||||||
// this
|
|
||||||
// 1. prevents deep copy that makes this.items == newList
|
|
||||||
// 2. filters out undesirable results
|
|
||||||
// 3. moves empty results to the bottom (sortedBy is a stable sort)
|
|
||||||
val new =
|
|
||||||
newList.map { it.copy(list = it.list.copy(list = it.list.list.filterSearchResponse())) }
|
|
||||||
.sortedBy { it.list.list.isEmpty() }
|
|
||||||
|
|
||||||
val diffResult = DiffUtil.calculateDiff(
|
|
||||||
SearchDiffCallback(items, new)
|
|
||||||
)
|
|
||||||
items.clear()
|
|
||||||
items.addAll(new)
|
|
||||||
|
|
||||||
//val mAdapter = this
|
|
||||||
val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) {
|
|
||||||
headItems
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
diffResult.dispatchUpdatesTo(object : ListUpdateCallback {
|
|
||||||
override fun onInserted(position: Int, count: Int) {
|
|
||||||
//notifyItemRangeChanged(position + delta, count)
|
|
||||||
notifyItemRangeInserted(position + delta, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRemoved(position: Int, count: Int) {
|
|
||||||
notifyItemRangeRemoved(position + delta, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMoved(fromPosition: Int, toPosition: Int) {
|
|
||||||
notifyItemMoved(fromPosition + delta, toPosition + delta)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onChanged(_position: Int, count: Int, payload: Any?) {
|
|
||||||
|
|
||||||
val position = _position + delta
|
|
||||||
|
|
||||||
// I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind
|
|
||||||
recyclerView?.apply {
|
|
||||||
// this loops every viewHolder in the recycle view and checks the position to see if it is within the update range
|
|
||||||
val missingUpdates = (position until (position + count)).toMutableSet()
|
|
||||||
for (i in 0 until itemCount) {
|
|
||||||
val child = getChildAt(i) ?: continue
|
|
||||||
val viewHolder = getChildViewHolder(child) ?: continue
|
|
||||||
if (viewHolder !is ParentViewHolder) continue
|
|
||||||
|
|
||||||
val absolutePosition = viewHolder.bindingAdapterPosition
|
|
||||||
if (absolutePosition >= position && absolutePosition < position + count) {
|
|
||||||
val expand = items.getOrNull(absolutePosition - delta) ?: continue
|
|
||||||
missingUpdates -= absolutePosition
|
|
||||||
//println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}")
|
|
||||||
if (viewHolder.title.text == expand.list.name) {
|
|
||||||
viewHolder.update(expand)
|
|
||||||
} else {
|
|
||||||
viewHolder.bind(expand)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// just in case some item did not get updated
|
|
||||||
for (i in missingUpdates) {
|
|
||||||
notifyItemChanged(i, payload)
|
|
||||||
}
|
|
||||||
} ?: run {
|
|
||||||
// in case we don't have a nice
|
|
||||||
notifyItemRangeChanged(position, count, payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
) {
|
||||||
//diffResult.dispatchUpdatesTo(this)
|
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
|
||||||
}
|
override fun save(): Bundle = Bundle().apply {
|
||||||
|
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
|
||||||
class ParentViewHolder
|
putParcelable(
|
||||||
constructor(
|
"value",
|
||||||
itemView: View,
|
recyclerView?.layoutManager?.onSaveInstanceState()
|
||||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
)
|
||||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
(recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView)
|
||||||
private val expandCallback: ((String) -> Unit)? = null,
|
|
||||||
) :
|
|
||||||
RecyclerView.ViewHolder(itemView) {
|
|
||||||
val title: TextView = itemView.home_child_more_info
|
|
||||||
val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
|
||||||
|
|
||||||
fun update(expand: HomeViewModel.ExpandableHomepageList) {
|
|
||||||
val info = expand.list
|
|
||||||
(recyclerView.adapter as? HomeChildItemAdapter?)?.apply {
|
|
||||||
updateList(info.list.toMutableList())
|
|
||||||
hasNext = expand.hasNext
|
|
||||||
} ?: run {
|
|
||||||
recyclerView.adapter = HomeChildItemAdapter(
|
|
||||||
info.list.toMutableList(),
|
|
||||||
clickCallback = clickCallback,
|
|
||||||
nextFocusUp = recyclerView.nextFocusUpId,
|
|
||||||
nextFocusDown = recyclerView.nextFocusDownId,
|
|
||||||
).apply {
|
|
||||||
isHorizontal = info.isHorizontalImages
|
|
||||||
}
|
|
||||||
recyclerView.setLinearListLayout()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(expand: HomeViewModel.ExpandableHomepageList) {
|
override fun restore(state: Bundle) {
|
||||||
val info = expand.list
|
(binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState(
|
||||||
recyclerView.adapter = HomeChildItemAdapter(
|
state.getSafeParcelable<Parcelable>("value")
|
||||||
info.list.toMutableList(),
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
|
||||||
|
super.submitList(list?.sortedBy { it.list.list.isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdateContent(
|
||||||
|
holder: ViewHolderState<Bundle>,
|
||||||
|
item: HomeViewModel.ExpandableHomepageList,
|
||||||
|
position: Int
|
||||||
|
) {
|
||||||
|
val binding = holder.view
|
||||||
|
if (binding !is HomepageParentBinding) return
|
||||||
|
(binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindContent(
|
||||||
|
holder: ViewHolderState<Bundle>,
|
||||||
|
item: HomeViewModel.ExpandableHomepageList,
|
||||||
|
position: Int
|
||||||
|
) {
|
||||||
|
val startFocus = R.id.nav_rail_view
|
||||||
|
val endFocus = FOCUS_SELF
|
||||||
|
val binding = holder.view
|
||||||
|
if (binding !is HomepageParentBinding) return
|
||||||
|
val info = item.list
|
||||||
|
binding.apply {
|
||||||
|
homeChildRecyclerview.adapter = HomeChildItemAdapter(
|
||||||
|
fragment = fragment,
|
||||||
|
id = id + position + 100,
|
||||||
clickCallback = clickCallback,
|
clickCallback = clickCallback,
|
||||||
nextFocusUp = recyclerView.nextFocusUpId,
|
nextFocusUp = homeChildRecyclerview.nextFocusUpId,
|
||||||
nextFocusDown = recyclerView.nextFocusDownId,
|
nextFocusDown = homeChildRecyclerview.nextFocusDownId,
|
||||||
).apply {
|
).apply {
|
||||||
isHorizontal = info.isHorizontalImages
|
isHorizontal = info.isHorizontalImages
|
||||||
hasNext = expand.hasNext
|
hasNext = item.hasNext
|
||||||
|
submitList(item.list.list)
|
||||||
}
|
}
|
||||||
recyclerView.setLinearListLayout()
|
homeChildRecyclerview.setLinearListLayout(
|
||||||
title.text = info.name
|
isHorizontal = true,
|
||||||
|
nextLeft = startFocus,
|
||||||
|
nextRight = endFocus,
|
||||||
|
)
|
||||||
|
homeChildMoreInfo.text = info.name
|
||||||
|
|
||||||
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
homeChildRecyclerview.addOnScrollListener(object :
|
||||||
|
RecyclerView.OnScrollListener() {
|
||||||
var expandCount = 0
|
var expandCount = 0
|
||||||
val name = expand.list.name
|
val name = item.list.name
|
||||||
|
|
||||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
override fun onScrollStateChanged(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
newState: Int
|
||||||
|
) {
|
||||||
super.onScrollStateChanged(recyclerView, newState)
|
super.onScrollStateChanged(recyclerView, newState)
|
||||||
|
|
||||||
val adapter = recyclerView.adapter
|
val adapter = recyclerView.adapter
|
||||||
|
|
@ -247,27 +140,40 @@ open class ParentItemAdapter(
|
||||||
})
|
})
|
||||||
|
|
||||||
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
|
//(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged()
|
||||||
if (!isTvSettings()) {
|
if (isLayout(PHONE)) {
|
||||||
title.setOnClickListener {
|
homeChildMoreInfo.setOnClickListener {
|
||||||
moreInfoClickCallback.invoke(expand)
|
moreInfoClickCallback.invoke(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreateContent(parent: ViewGroup): ParentItemHolder {
|
||||||
|
val layoutResId = when {
|
||||||
|
isLayout(TV) -> R.layout.homepage_parent_tv
|
||||||
|
isLayout(EMULATOR) -> R.layout.homepage_parent_emulator
|
||||||
|
else -> R.layout.homepage_parent
|
||||||
|
}
|
||||||
|
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
val binding = try {
|
||||||
|
HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logError(t)
|
||||||
|
// just in case someone forgot we don't want to crash
|
||||||
|
HomepageParentBinding.inflate(inflater)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParentItemHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateList(newList: List<HomePageList>) {
|
||||||
|
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
|
||||||
|
.toMutableList())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SearchDiffCallback(
|
@Suppress("DEPRECATION")
|
||||||
private val oldList: List<HomeViewModel.ExpandableHomepageList>,
|
inline fun <reified T> Bundle.getSafeParcelable(key: String): T? =
|
||||||
private val newList: List<HomeViewModel.ExpandableHomepageList>
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key)
|
||||||
) :
|
else getParcelable(key, T::class.java)
|
||||||
DiffUtil.Callback() {
|
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
oldList[oldItemPosition].list.name == newList[newItemPosition].list.name
|
|
||||||
|
|
||||||
override fun getOldListSize() = oldList.size
|
|
||||||
|
|
||||||
override fun getNewListSize() = newList.size
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
|
||||||
oldList[oldItemPosition] == newList[newItemPosition]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,102 +2,63 @@ package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.LayoutRes
|
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.lagradost.cloudstream3.LoadResponse
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
|
||||||
|
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
|
||||||
|
import com.lagradost.cloudstream3.ui.NoStateAdapter
|
||||||
|
import com.lagradost.cloudstream3.ui.ViewHolderState
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
|
|
||||||
import kotlinx.android.synthetic.main.home_scroll_view.view.*
|
|
||||||
|
|
||||||
|
|
||||||
class HomeScrollAdapter(
|
class HomeScrollAdapter(
|
||||||
@LayoutRes val layout: Int = R.layout.home_scroll_view,
|
fragment: Fragment
|
||||||
private val forceHorizontalPosters: Boolean? = null
|
) : NoStateAdapter<LoadResponse>(fragment) {
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
||||||
private var items: MutableList<LoadResponse> = mutableListOf()
|
|
||||||
var hasMoreItems: Boolean = false
|
var hasMoreItems: Boolean = false
|
||||||
|
|
||||||
fun getItem(position: Int): LoadResponse? {
|
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
|
||||||
return items.getOrNull(position)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
val binding = if (isLayout(TV or EMULATOR)) {
|
||||||
|
HomeScrollViewTvBinding.inflate(inflater, parent, false)
|
||||||
|
} else {
|
||||||
|
HomeScrollViewBinding.inflate(inflater, parent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ViewHolderState(binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setItems(newItems: List<LoadResponse>, hasNext: Boolean): Boolean {
|
override fun onBindContent(
|
||||||
val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url
|
holder: ViewHolderState<Any>,
|
||||||
hasMoreItems = hasNext
|
item: LoadResponse,
|
||||||
|
position: Int,
|
||||||
|
) {
|
||||||
|
val binding = holder.view
|
||||||
|
val itemView = holder.itemView
|
||||||
|
val isHorizontal =
|
||||||
|
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
val diffResult = DiffUtil.calculateDiff(
|
val posterUrl =
|
||||||
HomeScrollDiffCallback(this.items, newItems)
|
if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl
|
||||||
)
|
?: item.backgroundPosterUrl
|
||||||
|
|
||||||
items.clear()
|
when (binding) {
|
||||||
items.addAll(newItems)
|
is HomeScrollViewBinding -> {
|
||||||
|
binding.homeScrollPreview.setImage(posterUrl)
|
||||||
|
binding.homeScrollPreviewTags.apply {
|
||||||
|
text = item.tags?.joinToString(" • ") ?: ""
|
||||||
|
isGone = item.tags.isNullOrEmpty()
|
||||||
|
maxLines = 2
|
||||||
|
}
|
||||||
|
binding.homeScrollPreviewTitle.text = item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
is HomeScrollViewTvBinding -> {
|
||||||
diffResult.dispatchUpdatesTo(this)
|
binding.homeScrollPreview.setImage(posterUrl)
|
||||||
|
|
||||||
return isSame
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
return CardViewHolder(
|
|
||||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
|
||||||
forceHorizontalPosters
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is CardViewHolder -> {
|
|
||||||
holder.bind(items[position])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CardViewHolder
|
|
||||||
constructor(
|
|
||||||
itemView: View,
|
|
||||||
private val forceHorizontalPosters: Boolean? = null
|
|
||||||
) :
|
|
||||||
RecyclerView.ViewHolder(itemView) {
|
|
||||||
|
|
||||||
fun bind(card: LoadResponse) {
|
|
||||||
card.apply {
|
|
||||||
val isHorizontal =
|
|
||||||
(forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
|
|
||||||
|
|
||||||
val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl
|
|
||||||
?: backgroundPosterUrl
|
|
||||||
itemView.home_scroll_preview_tags?.text = tags?.joinToString(" • ") ?: ""
|
|
||||||
itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty()
|
|
||||||
itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders)
|
|
||||||
itemView.home_scroll_preview_title?.text = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HomeScrollDiffCallback(
|
|
||||||
private val oldList: List<LoadResponse>,
|
|
||||||
private val newList: List<LoadResponse>
|
|
||||||
) :
|
|
||||||
DiffUtil.Callback() {
|
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
oldList[oldItemPosition].url == newList[newItemPosition].url
|
|
||||||
|
|
||||||
override fun getOldListSize() = oldList.size
|
|
||||||
|
|
||||||
override fun getNewListSize() = newList.size
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
oldList[oldItemPosition] == newList[newItemPosition]
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
|
||||||
return items.size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,38 +1,59 @@
|
||||||
package com.lagradost.cloudstream3.ui.home
|
package com.lagradost.cloudstream3.ui.home
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.lagradost.cloudstream3.*
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.apis
|
import com.lagradost.cloudstream3.APIHolder.apis
|
||||||
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
import com.lagradost.cloudstream3.AcraApplication.Companion.context
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||||
import com.lagradost.cloudstream3.mvvm.*
|
import com.lagradost.cloudstream3.HomePageList
|
||||||
|
import com.lagradost.cloudstream3.LoadResponse
|
||||||
|
import com.lagradost.cloudstream3.MainAPI
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
|
||||||
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.amap
|
||||||
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||||
|
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository
|
import com.lagradost.cloudstream3.ui.APIRepository
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
|
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||||
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
|
||||||
|
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||||
|
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.*
|
import java.util.EnumSet
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
|
|
@ -72,7 +93,17 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var repo: APIRepository? = null
|
fun deleteResumeWatching() {
|
||||||
|
deleteAllResumeStateIds()
|
||||||
|
loadResumeWatching()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteBookmarks(list: List<SearchResponse>) {
|
||||||
|
list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) }
|
||||||
|
loadStoredData()
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo: APIRepository? = null
|
||||||
|
|
||||||
private val _apiName = MutableLiveData<String>()
|
private val _apiName = MutableLiveData<String>()
|
||||||
val apiName: LiveData<String> = _apiName
|
val apiName: LiveData<String> = _apiName
|
||||||
|
|
@ -83,7 +114,7 @@ class HomeViewModel : ViewModel() {
|
||||||
private var currentShuffledList: List<SearchResponse> = listOf()
|
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||||
|
|
||||||
private fun autoloadRepo(): APIRepository {
|
private fun autoloadRepo(): APIRepository {
|
||||||
return APIRepository(apis.first { it.hasMainPage })
|
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _availableWatchStatusTypes =
|
private val _availableWatchStatusTypes =
|
||||||
|
|
@ -95,14 +126,20 @@ class HomeViewModel : ViewModel() {
|
||||||
|
|
||||||
private val _resumeWatching = MutableLiveData<List<SearchResponse>>()
|
private val _resumeWatching = MutableLiveData<List<SearchResponse>>()
|
||||||
private val _preview = MutableLiveData<Resource<Pair<Boolean, List<LoadResponse>>>>()
|
private val _preview = MutableLiveData<Resource<Pair<Boolean, List<LoadResponse>>>>()
|
||||||
private val previewResponses = mutableListOf<LoadResponse>()
|
private val previewResponses = CopyOnWriteArrayList<LoadResponse>()
|
||||||
private val previewResponsesAdded = mutableSetOf<String>()
|
private val previewResponsesAdded = mutableSetOf<String>()
|
||||||
|
|
||||||
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
|
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
|
||||||
val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview
|
val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview
|
||||||
|
|
||||||
fun loadResumeWatching() = viewModelScope.launchSafe {
|
private fun loadResumeWatching() = viewModelScope.launchSafe {
|
||||||
val resumeWatchingResult = getResumeWatching()
|
val resumeWatchingResult = getResumeWatching()
|
||||||
|
if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
ioSafe {
|
||||||
|
// this WILL crash on non tvs, so keep this inside a try catch
|
||||||
|
activity?.addProgramsToContinueWatching(resumeWatchingResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
resumeWatchingResult?.let {
|
resumeWatchingResult?.let {
|
||||||
_resumeWatching.postValue(it)
|
_resumeWatching.postValue(it)
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +152,7 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}?.distinctBy { it.first } ?: return@launchSafe
|
}?.distinctBy { it.first } ?: return@launchSafe
|
||||||
|
|
||||||
val length = WatchType.values().size
|
val length = WatchType.entries.size
|
||||||
val currentWatchTypes = mutableSetOf<WatchType>()
|
val currentWatchTypes = mutableSetOf<WatchType>()
|
||||||
|
|
||||||
for (watch in watchStatusIds) {
|
for (watch in watchStatusIds) {
|
||||||
|
|
@ -128,6 +165,7 @@ class HomeViewModel : ViewModel() {
|
||||||
currentWatchTypes.remove(WatchType.NONE)
|
currentWatchTypes.remove(WatchType.NONE)
|
||||||
|
|
||||||
if (currentWatchTypes.size <= 0) {
|
if (currentWatchTypes.size <= 0) {
|
||||||
|
DataStoreHelper.homeBookmarkedList = intArrayOf()
|
||||||
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
|
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
|
||||||
_bookmarks.postValue(Pair(false, ArrayList()))
|
_bookmarks.postValue(Pair(false, ArrayList()))
|
||||||
return@launchSafe
|
return@launchSafe
|
||||||
|
|
@ -136,12 +174,13 @@ class HomeViewModel : ViewModel() {
|
||||||
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first())
|
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first())
|
||||||
//if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first())
|
//if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first())
|
||||||
|
|
||||||
|
DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray()
|
||||||
_availableWatchStatusTypes.postValue(
|
_availableWatchStatusTypes.postValue(
|
||||||
Pair(
|
|
||||||
watchPrefNotNull,
|
watchPrefNotNull to
|
||||||
currentWatchTypes,
|
currentWatchTypes,
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
val list = withContext(Dispatchers.IO) {
|
val list = withContext(Dispatchers.IO) {
|
||||||
watchStatusIds.filter { watchPrefNotNull.contains(it.second) }
|
watchStatusIds.filter { watchPrefNotNull.contains(it.second) }
|
||||||
|
|
@ -152,8 +191,11 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var onGoingLoad: Job? = null
|
private var onGoingLoad: Job? = null
|
||||||
private fun loadAndCancel(api: MainAPI?) {
|
private var isCurrentlyLoadingName: String? = null
|
||||||
|
private fun loadAndCancel(api: MainAPI) {
|
||||||
|
//println("loaded ${api.name}")
|
||||||
onGoingLoad?.cancel()
|
onGoingLoad?.cancel()
|
||||||
|
isCurrentlyLoadingName = api.name
|
||||||
onGoingLoad = load(api)
|
onGoingLoad = load(api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,12 +297,12 @@ class HomeViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load(api: MainAPI?) = ioSafe {
|
private fun load(api: MainAPI): Job = ioSafe {
|
||||||
repo = if (api != null) {
|
repo = //if (api != null) {
|
||||||
APIRepository(api)
|
APIRepository(api)
|
||||||
} else {
|
//} else {
|
||||||
autoloadRepo()
|
// autoloadRepo()
|
||||||
}
|
//}
|
||||||
|
|
||||||
_apiName.postValue(repo?.name)
|
_apiName.postValue(repo?.name)
|
||||||
_randomItems.postValue(listOf())
|
_randomItems.postValue(listOf())
|
||||||
|
|
@ -274,6 +316,7 @@ class HomeViewModel : ViewModel() {
|
||||||
|
|
||||||
_page.postValue(Resource.Loading())
|
_page.postValue(Resource.Loading())
|
||||||
_preview.postValue(Resource.Loading())
|
_preview.postValue(Resource.Loading())
|
||||||
|
// cancel the current preview expand as that is no longer relevant
|
||||||
addJob?.cancel()
|
addJob?.cancel()
|
||||||
|
|
||||||
when (val data = repo?.getMainPage(1, null)) {
|
when (val data = repo?.getMainPage(1, null)) {
|
||||||
|
|
@ -285,7 +328,13 @@ class HomeViewModel : ViewModel() {
|
||||||
val filteredList =
|
val filteredList =
|
||||||
context?.filterHomePageListByFilmQuality(list) ?: list
|
context?.filterHomePageListByFilmQuality(list) ?: list
|
||||||
expandable[list.name] =
|
expandable[list.name] =
|
||||||
ExpandableHomepageList(filteredList, 1, home.hasNext)
|
ExpandableHomepageList(
|
||||||
|
filteredList.copy(
|
||||||
|
list = CopyOnWriteArrayList(
|
||||||
|
filteredList.list
|
||||||
|
)
|
||||||
|
), 1, home.hasNext
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,8 +349,7 @@ class HomeViewModel : ViewModel() {
|
||||||
val currentList =
|
val currentList =
|
||||||
items.shuffled().filter { it.list.isNotEmpty() }
|
items.shuffled().filter { it.list.isNotEmpty() }
|
||||||
.flatMap { it.list }
|
.flatMap { it.list }
|
||||||
.distinctBy { it.url }
|
.distinctBy { it.url }.toList()
|
||||||
.toList()
|
|
||||||
|
|
||||||
if (currentList.isNotEmpty()) {
|
if (currentList.isNotEmpty()) {
|
||||||
val randomItems =
|
val randomItems =
|
||||||
|
|
@ -337,41 +385,142 @@ class HomeViewModel : ViewModel() {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Resource.Failure -> {
|
is Resource.Failure -> {
|
||||||
|
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
|
||||||
_page.postValue(data!!)
|
_page.postValue(data!!)
|
||||||
|
@Suppress("UNNECESSARY_NOT_NULL_ASSERTION")
|
||||||
_preview.postValue(data!!)
|
_preview.postValue(data!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
|
isCurrentlyLoadingName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun click(callback: SearchClickCallback) {
|
||||||
|
if (callback.action != SEARCH_ACTION_FOCUSED) {
|
||||||
|
SearchHelper.handleSearchClickCallback(callback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadAndCancel(preferredApiName: String?, forceReload: Boolean = true) =
|
|
||||||
viewModelScope.launchSafe {
|
private val _popup = MutableLiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?>(null)
|
||||||
|
val popup: LiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?> = _popup
|
||||||
|
|
||||||
|
fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) {
|
||||||
|
if (list == null)
|
||||||
|
_popup.postValue(null)
|
||||||
|
else
|
||||||
|
_popup.postValue(list to deleteCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bookmarksUpdated(unused: Boolean) {
|
||||||
|
reloadStored()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||||
|
loadAndCancel(DataStoreHelper.currentHomePage, forceReload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun afterMainPluginsLoaded(unused: Boolean = false) {
|
||||||
|
loadAndCancel(DataStoreHelper.currentHomePage, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadHome(unused: Boolean = false) {
|
||||||
|
loadAndCancel(DataStoreHelper.currentHomePage, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated
|
||||||
|
MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||||
|
MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded
|
||||||
|
MainActivity.reloadHomeEvent += ::reloadHome
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated
|
||||||
|
MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded
|
||||||
|
MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
|
||||||
|
MainActivity.reloadHomeEvent -= ::reloadHome
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun queryTextSubmit(query: String) {
|
||||||
|
QuickSearchFragment.pushSearch(
|
||||||
|
query,
|
||||||
|
repo?.name?.let { arrayOf(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fun queryTextChange(newText: String) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadStoredData() {
|
||||||
|
val list = EnumSet.noneOf(WatchType::class.java)
|
||||||
|
DataStoreHelper.homeBookmarkedList.map { WatchType.fromInternalId(it) }.let {
|
||||||
|
list.addAll(it)
|
||||||
|
}
|
||||||
|
loadStoredData(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadStored() {
|
||||||
|
loadResumeWatching()
|
||||||
|
loadStoredData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun click(load: LoadClickCallback) {
|
||||||
|
loadResult(load.response.url, load.response.apiName, load.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// only save the key if it is from UI, as we don't want internal functions changing the setting
|
||||||
|
fun loadAndCancel(
|
||||||
|
preferredApiName: String?,
|
||||||
|
forceReload: Boolean = true,
|
||||||
|
fromUI: Boolean = false
|
||||||
|
) =
|
||||||
|
ioSafe {
|
||||||
|
//println("trying to load $preferredApiName")
|
||||||
// Since plugins are loaded in stages this function can get called multiple times.
|
// Since plugins are loaded in stages this function can get called multiple times.
|
||||||
// The issue with this is that the homepage may be fetched multiple times while the first request is loading
|
// The issue with this is that the homepage may be fetched multiple times while the first request is loading
|
||||||
val api = getApiFromNameNull(preferredApiName)
|
// api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true
|
||||||
if (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) {
|
val currentPage = page.value
|
||||||
return@launchSafe
|
|
||||||
|
// if we don't need to reload and we have a valid homepage or currently loading the same thing then return
|
||||||
|
val currentLoading = isCurrentlyLoadingName
|
||||||
|
if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) {
|
||||||
|
return@ioSafe
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val api = getApiFromNameNull(preferredApiName)
|
||||||
if (preferredApiName == noneApi.name) {
|
if (preferredApiName == noneApi.name) {
|
||||||
setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name)
|
// just set to random
|
||||||
|
if (fromUI) DataStoreHelper.currentHomePage = noneApi.name
|
||||||
loadAndCancel(noneApi)
|
loadAndCancel(noneApi)
|
||||||
} else if (preferredApiName == randomApi.name) {
|
} else if (preferredApiName == randomApi.name) {
|
||||||
|
// randomize the api, if none exist like if not loaded or not installed
|
||||||
|
// then use nothing
|
||||||
val validAPIs = context?.filterProviderByPreferredMedia()
|
val validAPIs = context?.filterProviderByPreferredMedia()
|
||||||
if (validAPIs.isNullOrEmpty()) {
|
if (validAPIs.isNullOrEmpty()) {
|
||||||
// Do not set USER_SELECTED_HOMEPAGE_API when there is no plugins loaded
|
|
||||||
loadAndCancel(noneApi)
|
loadAndCancel(noneApi)
|
||||||
} else {
|
} else {
|
||||||
val apiRandom = validAPIs.random()
|
val apiRandom = validAPIs.random()
|
||||||
loadAndCancel(apiRandom)
|
loadAndCancel(apiRandom)
|
||||||
setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name)
|
if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name
|
||||||
}
|
}
|
||||||
// If the plugin isn't loaded yet. (Does not set the key)
|
|
||||||
} else if (api == null) {
|
} else if (api == null) {
|
||||||
loadAndCancel(noneApi)
|
// API is not found aka not loaded or removed, post the loading
|
||||||
|
// progress if waiting for plugins, otherwise nothing
|
||||||
|
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
|
||||||
|
loadAndCancel(noneApi)
|
||||||
|
} else {
|
||||||
|
_page.postValue(Resource.Loading())
|
||||||
|
if (preferredApiName != null)
|
||||||
|
_apiName.postValue(preferredApiName!!)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setKey(USER_SELECTED_HOMEPAGE_API, api.name)
|
// if the api is found, then set it to it and save key
|
||||||
|
if (fromUI) DataStoreHelper.currentHomePage = api.name
|
||||||
loadAndCancel(api)
|
loadAndCancel(api)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,73 @@
|
||||||
package com.lagradost.cloudstream3.ui.library
|
package com.lagradost.cloudstream3.ui.library
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.fragment.app.Fragment
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
|
||||||
|
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
|
||||||
import android.view.animation.AlphaAnimation
|
import android.view.animation.AlphaAnimation
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
|
import androidx.core.view.allViews
|
||||||
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import com.lagradost.cloudstream3.APIHolder
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
import com.lagradost.cloudstream3.MainActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.SearchResponse
|
||||||
|
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.Resource
|
import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
|
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
|
||||||
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
|
||||||
|
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
import kotlinx.android.synthetic.main.fragment_library.*
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
const val LIBRARY_FOLDER = "library_folder"
|
const val LIBRARY_FOLDER = "library_folder"
|
||||||
|
|
||||||
|
|
||||||
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
|
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
|
||||||
Default(R.string.default_subtitles), // TODO FIX AFTER MERGE
|
Default(R.string.action_default),
|
||||||
Provider(R.string.none),
|
Provider(R.string.none),
|
||||||
Browser(R.string.browser),
|
Browser(R.string.browser),
|
||||||
Search(R.string.search),
|
Search(R.string.search),
|
||||||
|
|
@ -63,6 +86,8 @@ data class ProviderLibraryData(
|
||||||
|
|
||||||
class LibraryFragment : Fragment() {
|
class LibraryFragment : Fragment() {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
|
||||||
fun newInstance() = LibraryFragment()
|
fun newInstance() = LibraryFragment()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,40 +98,85 @@ class LibraryFragment : Fragment() {
|
||||||
|
|
||||||
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
||||||
|
|
||||||
|
var binding: FragmentLibraryBinding? = null
|
||||||
|
private var toggleRandomButton = false
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
): View? {
|
): View {
|
||||||
return inflater.inflate(R.layout.fragment_library, container, false)
|
val layout =
|
||||||
|
if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library
|
||||||
|
val root = inflater.inflate(layout, container, false)
|
||||||
|
binding = try {
|
||||||
|
FragmentLibraryBinding.bind(root)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
CommonActivity.showToast(
|
||||||
|
txt(R.string.unable_to_inflate, t.message ?: ""),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
|
logError(t)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
|
||||||
|
//return inflater.inflate(R.layout.fragment_library, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
binding = null
|
||||||
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
viewpager?.currentItem?.let { currentItem ->
|
binding?.viewpager?.currentItem?.let { currentItem ->
|
||||||
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
|
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
|
||||||
}
|
}
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateRandom() {
|
||||||
|
val position = libraryViewModel.currentPage.value ?: 0
|
||||||
|
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
|
||||||
|
if (toggleRandomButton) {
|
||||||
|
listLibraryItems.clear()
|
||||||
|
listLibraryItems.addAll(pages[position].items)
|
||||||
|
binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty()
|
||||||
|
} else {
|
||||||
|
binding?.libraryRandom?.isGone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ResourceType", "CutPasteId")
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
context?.fixPaddingStatusbar(search_status_bar_padding)
|
fixPaddingStatusbar(binding?.searchStatusBarPadding)
|
||||||
|
|
||||||
sort_fab?.setOnClickListener {
|
binding?.sortFab?.setOnClickListener(sortChangeClickListener)
|
||||||
val methods = libraryViewModel.sortingMethods.map {
|
binding?.librarySort?.setOnClickListener(sortChangeClickListener)
|
||||||
txt(it.stringRes).asString(view.context)
|
|
||||||
|
binding?.libraryRoot?.findViewById<TextView>(R.id.search_src_text)?.apply {
|
||||||
|
tag = "tv_no_focus_tag"
|
||||||
|
//Expand the Appbar when search bar is focused, fixing scroll up issue
|
||||||
|
setOnFocusChangeListener { _, _ ->
|
||||||
|
binding?.searchBar?.setExpanded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
activity?.showBottomDialog(methods,
|
|
||||||
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
|
||||||
txt(R.string.sort_by).asString(view.context),
|
|
||||||
false,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
val method = libraryViewModel.sortingMethods[it]
|
|
||||||
libraryViewModel.sort(method)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
// Set the color for the search exit icon to the correct theme text color
|
||||||
|
val searchExitIcon =
|
||||||
|
binding?.mainSearch?.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
|
||||||
|
val searchExitIconColor = TypedValue()
|
||||||
|
|
||||||
|
activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true)
|
||||||
|
searchExitIcon?.setColorFilter(searchExitIconColor.data)
|
||||||
|
|
||||||
|
val searchCallback = Runnable {
|
||||||
|
val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
|
||||||
|
libraryViewModel.sort(ListSorting.Query, newText)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||||
libraryViewModel.sort(ListSorting.Query, query)
|
libraryViewModel.sort(ListSorting.Query, query)
|
||||||
return true
|
return true
|
||||||
|
|
@ -122,14 +192,19 @@ class LibraryFragment : Fragment() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryViewModel.sort(ListSorting.Query, newText)
|
binding?.mainSearch?.removeCallbacks(searchCallback)
|
||||||
|
|
||||||
|
// Delay the execution of the search operation by 1 second (adjust as needed)
|
||||||
|
// this prevents running search when the user is typing
|
||||||
|
binding?.mainSearch?.postDelayed(searchCallback, 1000)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
libraryViewModel.reloadPages(false)
|
libraryViewModel.reloadPages(false)
|
||||||
|
|
||||||
list_selector?.setOnClickListener {
|
binding?.listSelector?.setOnClickListener {
|
||||||
val items = libraryViewModel.availableApiNames
|
val items = libraryViewModel.availableApiNames
|
||||||
val currentItem = libraryViewModel.currentApiName.value
|
val currentItem = libraryViewModel.currentApiName.value
|
||||||
|
|
||||||
|
|
@ -143,6 +218,25 @@ class LibraryFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Load value for toggling Random button. Hide at startup
|
||||||
|
context?.let {
|
||||||
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||||
|
toggleRandomButton =
|
||||||
|
settingsManager.getBoolean(
|
||||||
|
getString(R.string.random_button_key),
|
||||||
|
false
|
||||||
|
) && isLayout(PHONE)
|
||||||
|
binding?.libraryRandom?.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
binding?.libraryRandom?.setOnClickListener {
|
||||||
|
if (listLibraryItems.isNotEmpty()) {
|
||||||
|
val listLibraryItem = listLibraryItems.random()
|
||||||
|
libraryViewModel.currentSyncApi?.syncIdName?.let {
|
||||||
|
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a plugin selection dialogue and saves the response
|
* Shows a plugin selection dialogue and saves the response
|
||||||
|
|
@ -152,12 +246,14 @@ class LibraryFragment : Fragment() {
|
||||||
syncId: SyncIdName,
|
syncId: SyncIdName,
|
||||||
apiName: String? = null,
|
apiName: String? = null,
|
||||||
) {
|
) {
|
||||||
val availableProviders = allProviders.filter {
|
val availableProviders = synchronized(allProviders) {
|
||||||
it.supportedSyncNames.contains(syncId)
|
allProviders.filter {
|
||||||
}.map { it.name } +
|
it.supportedSyncNames.contains(syncId)
|
||||||
// Add the api if it exists
|
}.map { it.name } +
|
||||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList())
|
// Add the api if it exists
|
||||||
|
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||||
|
?: emptyList())
|
||||||
|
}
|
||||||
val baseOptions = listOf(
|
val baseOptions = listOf(
|
||||||
LibraryOpenerType.Default,
|
LibraryOpenerType.Default,
|
||||||
LibraryOpenerType.None,
|
LibraryOpenerType.None,
|
||||||
|
|
@ -167,7 +263,7 @@ class LibraryFragment : Fragment() {
|
||||||
|
|
||||||
val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders
|
val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders
|
||||||
|
|
||||||
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, key)
|
val savedSelection = getKey<LibraryOpener>("$currentAccount/$LIBRARY_FOLDER", key)
|
||||||
val selectedIndex =
|
val selectedIndex =
|
||||||
when {
|
when {
|
||||||
savedSelection == null -> 0
|
savedSelection == null -> 0
|
||||||
|
|
@ -202,108 +298,84 @@ class LibraryFragment : Fragment() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setKey(
|
setKey(
|
||||||
LIBRARY_FOLDER,
|
"$currentAccount/$LIBRARY_FOLDER",
|
||||||
key,
|
key,
|
||||||
savedData,
|
savedData,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider_selector?.setOnClickListener {
|
binding?.providerSelector?.setOnClickListener {
|
||||||
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
||||||
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewpager?.setPageTransformer(LibraryScrollTransformer())
|
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
|
||||||
viewpager?.adapter =
|
|
||||||
viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean ->
|
binding?.viewpager?.adapter = ViewpagerAdapter(
|
||||||
|
fragment = this,
|
||||||
|
{ isScrollingDown: Boolean ->
|
||||||
if (isScrollingDown) {
|
if (isScrollingDown) {
|
||||||
sort_fab?.shrink()
|
binding?.sortFab?.shrink()
|
||||||
|
binding?.libraryRandom?.shrink()
|
||||||
} else {
|
} else {
|
||||||
sort_fab?.extend()
|
binding?.sortFab?.extend()
|
||||||
|
binding?.libraryRandom?.extend()
|
||||||
}
|
}
|
||||||
}) callback@{ searchClickCallback ->
|
}) callback@{ searchClickCallback ->
|
||||||
// To prevent future accidents
|
// To prevent future accidents
|
||||||
debugAssert({
|
debugAssert({
|
||||||
searchClickCallback.card !is SyncAPI.LibraryItem
|
searchClickCallback.card !is SyncAPI.LibraryItem
|
||||||
}, {
|
}, {
|
||||||
"searchClickCallback ${searchClickCallback.card} is not a LibraryItem"
|
"searchClickCallback ${searchClickCallback.card} is not a LibraryItem"
|
||||||
})
|
})
|
||||||
|
|
||||||
val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId
|
val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId
|
||||||
val syncName =
|
val syncName =
|
||||||
libraryViewModel.currentSyncApi?.syncIdName ?: return@callback
|
libraryViewModel.currentSyncApi?.syncIdName ?: return@callback
|
||||||
|
|
||||||
when (searchClickCallback.action) {
|
when (searchClickCallback.action) {
|
||||||
SEARCH_ACTION_SHOW_METADATA -> {
|
SEARCH_ACTION_SHOW_METADATA -> {
|
||||||
activity?.showPluginSelectionDialog(
|
(activity as? MainActivity)?.loadPopup(
|
||||||
|
searchClickCallback.card,
|
||||||
|
load = false
|
||||||
|
)
|
||||||
|
/*activity?.showPluginSelectionDialog(
|
||||||
syncId,
|
syncId,
|
||||||
syncName,
|
syncName,
|
||||||
searchClickCallback.card.apiName
|
searchClickCallback.card.apiName
|
||||||
)
|
)*/
|
||||||
}
|
}
|
||||||
|
|
||||||
SEARCH_ACTION_LOAD -> {
|
SEARCH_ACTION_LOAD -> {
|
||||||
// This basically first selects the individual opener and if that is default then
|
loadLibraryItem(syncName, syncId, searchClickCallback.card)
|
||||||
// selects the whole list opener
|
|
||||||
val savedListSelection =
|
|
||||||
getKey<LibraryOpener>(LIBRARY_FOLDER, syncName.name)
|
|
||||||
val savedSelection = getKey<LibraryOpener>(LIBRARY_FOLDER, syncId).takeIf {
|
|
||||||
it?.openType != LibraryOpenerType.Default
|
|
||||||
} ?: savedListSelection
|
|
||||||
|
|
||||||
when (savedSelection?.openType) {
|
|
||||||
null, LibraryOpenerType.Default -> {
|
|
||||||
// Prevents opening MAL/AniList as a provider
|
|
||||||
if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) {
|
|
||||||
activity?.loadSearchResult(
|
|
||||||
searchClickCallback.card
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Search when no provider can open
|
|
||||||
QuickSearchFragment.pushSearch(
|
|
||||||
activity,
|
|
||||||
searchClickCallback.card.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LibraryOpenerType.None -> {}
|
|
||||||
LibraryOpenerType.Provider ->
|
|
||||||
savedSelection.providerData?.apiName?.let { apiName ->
|
|
||||||
activity?.loadResult(
|
|
||||||
searchClickCallback.card.url,
|
|
||||||
apiName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LibraryOpenerType.Browser ->
|
|
||||||
openBrowser(searchClickCallback.card.url)
|
|
||||||
LibraryOpenerType.Search -> {
|
|
||||||
QuickSearchFragment.pushSearch(
|
|
||||||
activity,
|
|
||||||
searchClickCallback.card.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewpager?.offscreenPageLimit = 2
|
binding?.apply {
|
||||||
viewpager?.reduceDragSensitivity()
|
viewpager.offscreenPageLimit = 2
|
||||||
|
viewpager.reduceDragSensitivity()
|
||||||
|
searchBar.setExpanded(true)
|
||||||
|
}
|
||||||
|
|
||||||
val startLoading = Runnable {
|
val startLoading = Runnable {
|
||||||
gridview?.numColumns = context?.getSpanCount() ?: 3
|
binding?.apply {
|
||||||
gridview?.adapter =
|
gridview.numColumns = context?.getSpanCount() ?: 3
|
||||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
gridview.adapter =
|
||||||
library_loading_overlay?.isVisible = true
|
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||||
library_loading_shimmer?.startShimmer()
|
libraryLoadingOverlay.isVisible = true
|
||||||
empty_list_textview?.isVisible = false
|
libraryLoadingShimmer.startShimmer()
|
||||||
|
emptyListTextview.isVisible = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val stopLoading = Runnable {
|
val stopLoading = Runnable {
|
||||||
gridview?.adapter = null
|
binding?.apply {
|
||||||
library_loading_overlay?.isVisible = false
|
gridview.adapter = null
|
||||||
library_loading_shimmer?.stopShimmer()
|
libraryLoadingOverlay.isVisible = false
|
||||||
|
libraryLoadingShimmer.stopShimmer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val handler = Handler(Looper.getMainLooper())
|
val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
@ -314,65 +386,109 @@ class LibraryFragment : Fragment() {
|
||||||
handler.removeCallbacks(startLoading)
|
handler.removeCallbacks(startLoading)
|
||||||
val pages = resource.value
|
val pages = resource.value
|
||||||
val showNotice = pages.all { it.items.isEmpty() }
|
val showNotice = pages.all { it.items.isEmpty() }
|
||||||
empty_list_textview?.isVisible = showNotice
|
|
||||||
if (showNotice) {
|
binding?.apply {
|
||||||
if (libraryViewModel.availableApiNames.size > 1) {
|
emptyListTextview.isVisible = showNotice
|
||||||
empty_list_textview?.setText(R.string.empty_library_logged_in_message)
|
if (showNotice) {
|
||||||
} else {
|
if (libraryViewModel.availableApiNames.size > 1) {
|
||||||
empty_list_textview?.setText(R.string.empty_library_no_accounts_message)
|
emptyListTextview.setText(R.string.empty_library_logged_in_message)
|
||||||
|
} else {
|
||||||
|
emptyListTextview.setText(R.string.empty_library_no_accounts_message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map {
|
||||||
|
it.copy(
|
||||||
|
items = CopyOnWriteArrayList(it.items)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
//fix focus on the viewpager itself
|
||||||
|
(viewpager.getChildAt(0) as RecyclerView).apply {
|
||||||
|
tag = "tv_no_focus_tag"
|
||||||
|
//isFocusable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using notifyItemRangeChanged keeps the animations when sorting
|
||||||
|
/*viewpager.adapter?.notifyItemRangeChanged(
|
||||||
|
0,
|
||||||
|
viewpager.adapter?.itemCount ?: 0
|
||||||
|
)*/
|
||||||
|
|
||||||
|
libraryViewModel.currentPage.value?.let { page ->
|
||||||
|
binding?.viewpager?.setCurrentItem(page, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRandom()
|
||||||
|
|
||||||
|
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
||||||
|
// Without this there would be a flashing effect:
|
||||||
|
// loading -> show old viewpager -> black screen -> show new viewpager
|
||||||
|
handler.postDelayed(stopLoading, 300)
|
||||||
|
|
||||||
|
savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos ->
|
||||||
|
if (currentPos < 0) return@let
|
||||||
|
viewpager.setCurrentItem(currentPos, false)
|
||||||
|
// Using remove() sets the key to 0 instead of removing it
|
||||||
|
savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since the animation to scroll multiple items is so much its better to just hide
|
||||||
|
// the viewpager a bit while the fastest animation is running
|
||||||
|
fun hideViewpager(distance: Int) {
|
||||||
|
if (distance < 3) return
|
||||||
|
|
||||||
|
val hideAnimation = AlphaAnimation(1f, 0f).apply {
|
||||||
|
duration = distance * 50L
|
||||||
|
fillAfter = true
|
||||||
|
}
|
||||||
|
val showAnimation = AlphaAnimation(0f, 1f).apply {
|
||||||
|
duration = distance * 50L
|
||||||
|
startOffset = distance * 100L
|
||||||
|
fillAfter = true
|
||||||
|
}
|
||||||
|
viewpager.startAnimation(hideAnimation)
|
||||||
|
viewpager.startAnimation(showAnimation)
|
||||||
|
}
|
||||||
|
|
||||||
|
TabLayoutMediator(
|
||||||
|
libraryTabLayout,
|
||||||
|
viewpager,
|
||||||
|
) { tab, position ->
|
||||||
|
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
|
||||||
|
tab.view.tag = "tv_no_focus_tag"
|
||||||
|
tab.view.nextFocusDownId = R.id.search_result_root
|
||||||
|
|
||||||
|
tab.view.setOnClickListener {
|
||||||
|
val currentItem =
|
||||||
|
binding?.viewpager?.currentItem ?: return@setOnClickListener
|
||||||
|
val distance = abs(position - currentItem)
|
||||||
|
hideViewpager(distance)
|
||||||
|
}
|
||||||
|
//Expand the appBar on tab focus
|
||||||
|
tab.view.setOnFocusChangeListener { _, _ ->
|
||||||
|
binding?.searchBar?.setExpanded(true)
|
||||||
|
}
|
||||||
|
}.attach()
|
||||||
|
|
||||||
|
binding?.libraryTabLayout?.addOnTabSelectedListener(object :
|
||||||
|
TabLayout.OnTabSelectedListener {
|
||||||
|
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||||
|
binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
|
||||||
|
libraryViewModel.switchPage(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
|
||||||
|
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
(viewpager.adapter as? ViewpagerAdapter)?.pages = pages
|
|
||||||
// Using notifyItemRangeChanged keeps the animations when sorting
|
|
||||||
viewpager.adapter?.notifyItemRangeChanged(0, viewpager.adapter?.itemCount ?: 0)
|
|
||||||
|
|
||||||
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
|
|
||||||
// Without this there would be a flashing effect:
|
|
||||||
// loading -> show old viewpager -> black screen -> show new viewpager
|
|
||||||
handler.postDelayed(stopLoading, 300)
|
|
||||||
|
|
||||||
savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos ->
|
|
||||||
if (currentPos < 0) return@let
|
|
||||||
viewpager?.setCurrentItem(currentPos, false)
|
|
||||||
// Using remove() sets the key to 0 instead of removing it
|
|
||||||
savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since the animation to scroll multiple items is so much its better to just hide
|
|
||||||
// the viewpager a bit while the fastest animation is running
|
|
||||||
fun hideViewpager(distance: Int) {
|
|
||||||
if (distance < 3) return
|
|
||||||
|
|
||||||
val hideAnimation = AlphaAnimation(1f, 0f).apply {
|
|
||||||
duration = distance * 50L
|
|
||||||
fillAfter = true
|
|
||||||
}
|
|
||||||
val showAnimation = AlphaAnimation(0f, 1f).apply {
|
|
||||||
duration = distance * 50L
|
|
||||||
startOffset = distance * 100L
|
|
||||||
fillAfter = true
|
|
||||||
}
|
|
||||||
viewpager?.startAnimation(hideAnimation)
|
|
||||||
viewpager?.startAnimation(showAnimation)
|
|
||||||
}
|
|
||||||
|
|
||||||
TabLayoutMediator(
|
|
||||||
library_tab_layout,
|
|
||||||
viewpager,
|
|
||||||
) { tab, position ->
|
|
||||||
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
|
|
||||||
tab.view.setOnClickListener {
|
|
||||||
val currentItem = viewpager?.currentItem ?: return@setOnClickListener
|
|
||||||
val distance = abs(position - currentItem)
|
|
||||||
hideViewpager(distance)
|
|
||||||
}
|
|
||||||
}.attach()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is Resource.Loading -> {
|
is Resource.Loading -> {
|
||||||
// Only start loading after 200ms to prevent loading cached lists
|
// Only start loading after 200ms to prevent loading cached lists
|
||||||
handler.postDelayed(startLoading, 200)
|
handler.postDelayed(startLoading, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Resource.Failure -> {
|
is Resource.Failure -> {
|
||||||
stopLoading.run()
|
stopLoading.run()
|
||||||
// No user indication it failed :(
|
// No user indication it failed :(
|
||||||
|
|
@ -380,16 +496,108 @@ class LibraryFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
observe(libraryViewModel.currentPage) { position ->
|
||||||
|
updateRandom()
|
||||||
|
val all = binding?.viewpager?.allViews?.toList()
|
||||||
|
?.filterIsInstance<AutofitRecyclerView>()
|
||||||
|
|
||||||
|
all?.forEach { view ->
|
||||||
|
view.isVisible = view.tag == position
|
||||||
|
view.isFocusable = view.tag == position
|
||||||
|
|
||||||
|
if (view.tag == position)
|
||||||
|
view.descendantFocusability = FOCUS_AFTER_DESCENDANTS
|
||||||
|
else
|
||||||
|
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*binding?.viewpager?.registerOnPageChangeCallback(object :
|
||||||
|
ViewPager2.OnPageChangeCallback() {
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
|
||||||
|
super.onPageSelected(position)
|
||||||
|
}
|
||||||
|
})*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadLibraryItem(
|
||||||
|
syncName: SyncIdName,
|
||||||
|
syncId: String,
|
||||||
|
card: SearchResponse
|
||||||
|
) {
|
||||||
|
// This basically first selects the individual opener and if that is default then
|
||||||
|
// selects the whole list opener
|
||||||
|
val savedListSelection =
|
||||||
|
getKey<LibraryOpener>("$currentAccount/$LIBRARY_FOLDER", syncName.name)
|
||||||
|
|
||||||
|
val savedSelection = getKey<LibraryOpener>(
|
||||||
|
"$currentAccount/$LIBRARY_FOLDER",
|
||||||
|
syncId
|
||||||
|
).takeIf {
|
||||||
|
it?.openType != LibraryOpenerType.Default
|
||||||
|
} ?: savedListSelection
|
||||||
|
|
||||||
|
when (savedSelection?.openType) {
|
||||||
|
null, LibraryOpenerType.Default -> {
|
||||||
|
// Prevents opening MAL/AniList as a provider
|
||||||
|
if (APIHolder.getApiFromNameNull(card.apiName) != null) {
|
||||||
|
activity?.loadSearchResult(
|
||||||
|
card
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Search when no provider can open
|
||||||
|
QuickSearchFragment.pushSearch(
|
||||||
|
activity,
|
||||||
|
card.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LibraryOpenerType.None -> {}
|
||||||
|
LibraryOpenerType.Provider ->
|
||||||
|
savedSelection.providerData?.apiName?.let { apiName ->
|
||||||
|
activity?.loadResult(
|
||||||
|
card.url,
|
||||||
|
apiName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LibraryOpenerType.Browser ->
|
||||||
|
openBrowser(card.url)
|
||||||
|
|
||||||
|
LibraryOpenerType.Search -> {
|
||||||
|
QuickSearchFragment.pushSearch(
|
||||||
|
activity,
|
||||||
|
card.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
(viewpager.adapter as? ViewpagerAdapter)?.rebind()
|
binding?.viewpager?.adapter?.notifyDataSetChanged()
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class MenuSearchView(context: Context) : SearchView(context) {
|
private val sortChangeClickListener = View.OnClickListener { view ->
|
||||||
override fun onActionViewCollapsed() {
|
val methods = libraryViewModel.sortingMethods.map {
|
||||||
super.onActionViewCollapsed()
|
txt(it.stringRes).asString(view.context)
|
||||||
|
}
|
||||||
|
|
||||||
|
activity?.showBottomDialog(methods,
|
||||||
|
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
|
||||||
|
txt(R.string.sort_by).asString(view.context),
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
val method = libraryViewModel.sortingMethods[it]
|
||||||
|
libraryViewModel.sort(method)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MenuSearchView(context: Context) : SearchView(context)
|
||||||
|
|
@ -2,13 +2,13 @@ package com.lagradost.cloudstream3.ui.library
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
|
import com.lagradost.cloudstream3.R
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class LibraryScrollTransformer : ViewPager2.PageTransformer {
|
class LibraryScrollTransformer : ViewPager2.PageTransformer {
|
||||||
override fun transformPage(page: View, position: Float) {
|
override fun transformPage(page: View, position: Float) {
|
||||||
val padding = (-position * page.width).roundToInt()
|
val padding = (-position * page.width).roundToInt()
|
||||||
page.page_recyclerview.setPadding(
|
page.findViewById<View>(R.id.page_recyclerview).setPadding(
|
||||||
padding, 0,
|
padding, 0,
|
||||||
-padding, 0
|
-padding, 0
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue