mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-02 14:19:56 +00:00
Compare commits
841 commits
Author | SHA1 | Date | |
---|---|---|---|
|
a882d04d26 | ||
|
c4d06c1ba2 | ||
|
4dfcd47901 | ||
|
4c0c1abb4b | ||
|
6f44071186 | ||
|
29910a2698 | ||
|
b5da0d4462 | ||
|
99fb9b3462 | ||
|
5f0a89d13b | ||
|
f311561e6f | ||
|
2fc944ddd9 | ||
|
a2970b86ee | ||
|
ac9a51f105 | ||
|
90dca2537a | ||
|
4df227147c | ||
|
1fb55dca0a | ||
|
3d7b347e49 | ||
|
769ec9f59a | ||
|
dee310de3d | ||
|
0af4bad906 | ||
|
4731673ba3 | ||
|
8745221cbd | ||
|
742d95440e | ||
|
180b320cd7 | ||
|
cc8dffc485 | ||
|
a64fd2cf35 | ||
|
4aceb364d9 | ||
|
76d9bac0ec | ||
|
2b8dc41d0d | ||
|
33430c538c | ||
|
03e9cb398b | ||
|
2aef2ebec1 | ||
|
5e5fffbf97 | ||
|
51ac604e31 | ||
|
4e49b5bc63 | ||
|
658cbc5e00 | ||
|
2ceb4c5644 | ||
|
2738954af7 |
||
|
db5aaf0b84 |
||
|
e1abb7f8ae |
||
|
3310ac6008 | ||
|
09879c83e9 |
||
|
7aa8b6bc14 |
||
|
cac8a8fde4 |
||
|
01cb544dfd | ||
|
b9239b6177 | ||
|
96ca3f62a2 | ||
|
73ad783881 | ||
|
3bfcf65535 | ||
|
8b3b27a2a8 | ||
|
a4d4835a89 | ||
|
56c0f7bfaf | ||
|
736424ae35 | ||
|
37dc778009 | ||
|
cd3cea58a4 | ||
|
8b53e9e5e3 | ||
|
08e98b089c | ||
|
5528d71da8 | ||
|
83f520ca44 | ||
|
cc247ce634 | ||
|
c6caa59a90 | ||
|
00e28b9ce0 | ||
|
334f58979a | ||
|
940bf163da | ||
|
2bbe0e6133 | ||
|
861f34a287 | ||
|
82f214f155 | ||
|
4ee127fe13 | ||
|
86a4cf8d84 | ||
|
2c463dd5a1 | ||
|
ed3820bec0 | ||
|
542a7f212d | ||
|
8fb0826d69 | ||
|
deeaa55f56 | ||
|
5b954727a1 | ||
|
fae77c1a63 | ||
|
b69402dfe9 | ||
|
1f3e306a59 | ||
|
a9605118fb | ||
|
d22e918273 | ||
|
bdcb94055a | ||
|
d0644d39da | ||
|
8f3f776e22 | ||
|
548752e240 | ||
|
7f20250951 | ||
|
4d720b1d81 | ||
|
1e4aefb7d5 |
||
|
2a825a9f83 |
||
|
a8921a1aba | ||
|
edb9eda0a9 | ||
|
3a81676447 | ||
|
6695774037 |
||
|
03132ff77b | ||
|
49ddecdea4 | ||
|
a10bc8c7de |
||
|
c1e6e401cc |
||
|
44ff951ec6 | ||
|
11319e0ec5 | ||
|
100e98a960 | ||
|
c6100ede70 | ||
|
a2986a72bd | ||
|
e0e90c5f74 | ||
|
11992af81b | ||
|
15d771f7fc | ||
|
5ede474253 | ||
|
7922aa6f80 | ||
|
0c1333fa15 | ||
|
53b9ba0368 | ||
|
c3a8877796 | ||
|
a464ae9df5 | ||
|
98b6213886 |
||
|
b6671c653c |
||
|
55d042bee3 |
||
|
0d16dd0006 | ||
|
48a96140a7 | ||
|
603ef8f295 | ||
|
ab07288ba0 | ||
|
c0bbe5d491 | ||
|
b953ff21e7 | ||
|
c14378b534 | ||
|
80034ad131 |
||
|
33d3d9a29c | ||
|
7e83793586 | ||
|
6ba9ec8bc2 | ||
|
0b02ab0e2d | ||
|
ff531b5e77 | ||
|
b3f9de3b83 | ||
|
86bd71b89c | ||
|
2fca7e9a01 | ||
|
2cc873ef60 | ||
|
7a66ce6bcd | ||
|
2730569b6b | ||
|
ede5c4409c | ||
|
0dbe398435 | ||
|
bcab3bccbc | ||
|
58c9aeb1a2 | ||
|
4702787784 | ||
|
30c41044da |
||
|
e369676808 |
||
|
2fa9e65bee |
||
|
cf96bd1ec0 |
||
|
1f5a069877 |
||
|
13100dc38d | ||
|
5227041398 | ||
|
8491d4da1a | ||
|
9bea1563ca | ||
|
adc5013ea4 |
||
|
9e7b936663 | ||
|
19c84475db | ||
|
515c5e00e9 |
||
|
4164b1a3f8 | ||
|
a9dc038190 | ||
|
2825db88a5 | ||
|
363099b303 | ||
|
5e25a5054f | ||
|
2bc6127f6b | ||
|
064824aedf | ||
|
52044edb2e | ||
|
fb12073a82 |
||
|
9944842a2f |
||
|
99dc50894c |
||
|
de39451f67 | ||
|
8f28653b28 | ||
|
6598dff6df |
||
|
389798457b |
||
|
ba9f843368 |
||
|
623c47fa2e |
||
|
19861fe812 |
||
|
dd1c04bea1 |
||
|
e6159117f6 | ||
|
0d9e1cd3c5 | ||
|
10753eb879 | ||
|
29aec21095 | ||
|
a810f82ce2 | ||
|
2c454a0ec5 | ||
|
d3dca00482 | ||
|
d08dffd9e2 | ||
|
5b50ac926e | ||
|
57a3be35d0 | ||
|
70f36e69e6 | ||
|
8e70f1b865 |
||
|
f86fb0ee44 |
||
|
fe0aac7c6e | ||
|
b93447f712 | ||
|
84a5103526 | ||
|
c333300906 |
||
|
c94c2721d7 | ||
|
0428c1191a | ||
|
8208f92802 | ||
|
3d33c4b8e0 | ||
|
d3210ec12a | ||
|
c959b973fc | ||
|
40c195d4a0 | ||
|
f4f1470153 | ||
|
401999b5ea | ||
|
7b53315046 | ||
|
4d170db5e0 | ||
|
fa8d175101 | ||
|
cbef605f22 | ||
|
cf95791dcc | ||
|
919567dbdb | ||
|
8ca317a38a | ||
|
ccc686ed50 | ||
|
e3e7b0c345 | ||
|
5b0f359944 | ||
|
29f1bef099 | ||
|
0653f88c49 |
||
|
4ce9f64808 |
||
|
418f34c7e8 | ||
|
21c2ab21b2 | ||
|
1ace7318f3 | ||
|
48052b88db | ||
|
715c60dc6e | ||
|
916d052688 | ||
|
993b812c3b | ||
|
43887586b5 | ||
|
03d53f21a3 | ||
|
23d7e8e5b6 | ||
|
cce117c585 | ||
|
303bd1b805 | ||
|
c7f4a40342 |
||
|
208c6c0776 | ||
|
4fa0229ccb |
||
|
7d5c8347ce | ||
|
bd70131252 | ||
|
43a373eceb | ||
|
5bb3466ffe | ||
|
75e97ed008 |
||
|
ee28604c11 |
||
|
a7d89e1bfb | ||
|
cbfd9ea559 | ||
|
dae50c3bc3 | ||
|
e651e59dc4 | ||
|
80d78761bf | ||
|
fb85aa4f32 | ||
|
9635c95efe | ||
|
033a237488 | ||
|
ec22c58822 | ||
|
274942b5ba | ||
|
94ab3da0e4 | ||
|
5d44f0f2b6 | ||
|
f051e6b452 |
||
|
46a4284253 | ||
|
0a708c6892 |
||
|
0f96164dc3 | ||
|
91c4917021 |
||
|
c32ebe016b |
||
|
ea26eefc2d |
||
|
418f4a6075 |
||
|
0ec921709a | ||
|
e0811cfd93 | ||
|
f6b0778eb6 | ||
|
18aec34c0e |
||
|
bd185776e7 | ||
|
fca5fe38bb | ||
|
1c2c7b376d |
||
|
670df86114 | ||
|
55fb4d4562 | ||
|
c703d018bd | ||
|
425a27e130 | ||
|
bd1b0e875b |
||
|
1509c11f64 |
||
|
57c1097fbc |
||
|
1d1728b92b |
||
|
8202513993 |
||
|
5f9f6dbde8 | ||
|
cc3639180b |
||
|
8aa4de7522 |
||
|
ed1f7e7c72 | ||
|
1ecd1f5e04 | ||
|
1aa9adc899 | ||
|
f8b2da93b9 | ||
|
b794ff47ef | ||
|
6962a0547a | ||
|
b906c1d36b |
||
|
af337b1874 | ||
|
542235cca0 | ||
|
f5673425b7 | ||
|
94965cf3ba | ||
|
120ded5274 | ||
|
705eb6a3fa | ||
|
1eb62b31d2 | ||
|
b145187fa8 |
||
|
4da1e44fd1 |
||
|
4e70279982 |
||
|
233c8ee26e |
||
|
875adb4d79 | ||
|
456514c4d4 | ||
|
dac1918b95 | ||
|
1d7429ad86 | ||
|
5d0e6615ab | ||
|
dc415df8c0 | ||
|
45ce251c4c | ||
|
2bc702112f | ||
|
abd73bf797 | ||
|
e7e67b9572 |
||
|
1a58b693c1 |
||
|
50ecb909b4 |
||
|
5e480be8db | ||
|
48a67e51a6 | ||
|
5052bad824 | ||
|
5be92052bb | ||
|
e20945692e | ||
|
191a6e2460 | ||
|
c813fb4fad | ||
|
bf7001b578 | ||
|
18102a2a73 | ||
|
780c1dbde1 | ||
|
879aab0d99 | ||
|
6f37bc2f5d | ||
|
fc59b841d6 | ||
|
c07fcdd489 | ||
|
a49db10ade | ||
|
77bae98d77 | ||
|
254df7211c | ||
|
f9caab48c4 | ||
|
e0b5e7b808 | ||
|
ac3a8da002 | ||
|
1aa45c2156 | ||
|
3cf8abd409 | ||
|
db8426779c | ||
|
b419e033f3 | ||
|
d686fa327b | ||
|
a1ce5eda43 | ||
|
1e790d1aa9 | ||
|
d1d304b758 | ||
|
e12b500144 | ||
|
bd77651a1e | ||
|
35dc186395 | ||
|
07e78e0d12 | ||
|
5b8905c1d2 | ||
|
158a27cbae | ||
|
5769b39d78 | ||
|
5c96262c75 | ||
|
766f57dc9d | ||
|
9986078582 | ||
|
e047ab5684 | ||
|
a100785ad7 | ||
|
156eb4d15e | ||
|
dabcfd965f | ||
|
d44a71f3be | ||
|
f8edd6cf3d | ||
|
2baf53c5a4 | ||
|
c26e9c281f | ||
|
9f78e9b7dd | ||
|
fdaf41b605 | ||
|
89526efe7a | ||
|
5e3a25c18f | ||
|
cf11c4283e | ||
|
2dde04b979 | ||
|
8384f227be | ||
|
697b3bc5f5 | ||
|
9e2041521e | ||
|
ee7b89ec6e | ||
|
5b143bdc76 | ||
|
e3800426c9 |
||
|
d9d00e452e | ||
|
14500e281c | ||
|
c4623c80ff | ||
|
9e17dce9a9 | ||
|
4acc867634 |
||
|
1a061268de |
||
|
daa91986ef | ||
|
63761cfc9a | ||
|
5091a5485a |
||
|
d10026acd1 | ||
|
f8f1cababe |
||
|
ad46841397 |
||
|
20fb1e0fd0 | ||
|
9347351c37 | ||
|
42dd8d6152 |
||
|
0ef1f2d40f | ||
|
b460f9915d | ||
|
4e195dfbc3 | ||
|
38b9fe3017 | ||
|
0a839b4814 |
||
|
3c7f7bfca7 | ||
|
05230971b3 | ||
|
586db317dd |
||
|
ae36a24ad1 |
||
|
dccdf72c73 | ||
|
ca15983a72 | ||
|
4b6a2c9829 | ||
|
9a435f8859 |
||
|
81162c5df2 |
||
|
c7c3ddfc96 |
||
|
830d3a9022 |
||
|
a1c2d19daf |
||
|
1755d03a6b | ||
|
869b1fc15e | ||
|
ce2a2f8582 | ||
|
7b355139fb | ||
|
b14518edb1 | ||
|
7d64003d1c | ||
|
0a59e04f19 | ||
|
b57abb646f | ||
|
dd6bde97a9 | ||
|
bd87a47551 |
||
|
b545545712 | ||
|
c1993ffa03 | ||
|
7f7ebafa46 | ||
|
b652597924 | ||
|
258fe77928 | ||
|
5a9fcd6fab | ||
|
3c05521a5b | ||
|
034b8b15ae | ||
|
7bd687331b | ||
|
bdae35b1a8 | ||
|
54d58df4b6 | ||
|
76103a2a8c |
||
|
9165a9f7cb | ||
|
b556d1e81d | ||
|
7c25678211 | ||
|
f63f9dd6db |
||
|
c83a9924e2 | ||
|
bbeb9b83a0 | ||
|
06478f3e36 | ||
|
40f20002b2 | ||
|
442272f517 | ||
|
88dae8e9c4 | ||
|
1bbfa7d39e | ||
|
edc2b3d295 | ||
|
0006da7385 | ||
|
470b7bd2e5 |
||
|
9014fb581d |
||
|
b5ac8b3ec6 | ||
|
78f5169880 |
||
|
7ffa6b1bb3 |
||
|
3cd4b4503f |
||
|
3361b77aec | ||
|
8b7c9df286 | ||
|
157d5b4c36 | ||
|
44c8800bec | ||
|
2f0ba1b1f7 | ||
|
36c51f1a0c | ||
|
1dfe18aa6f | ||
|
b9bbfb44c5 | ||
|
83843f192d | ||
|
8839d9f1c6 | ||
|
0630ec1d46 | ||
|
4dce8d6a80 | ||
|
3b62f999bf | ||
|
d63fa521a1 |
||
|
ca781dfe15 |
||
|
4bc561ceab |
||
|
3d258180bd |
||
|
65ae8610fd | ||
|
c1c2000c98 | ||
|
287c2d82a1 | ||
|
5cde1650f4 | ||
|
a4b90f14ab | ||
|
4826b40136 | ||
|
62618224da | ||
|
49f15e1637 |
||
|
d5cab0910e |
||
|
d4ccf232c1 |
||
|
e36047c890 | ||
|
8f1199bd08 | ||
|
d6e045ea4e | ||
|
304e48996b | ||
|
f350dc83b8 | ||
|
ebb7beda8c | ||
|
a01f3da66e | ||
|
72f5b5fbc0 | ||
|
330aa495c8 | ||
|
0b529ae94d | ||
|
83b35183d0 | ||
|
daf1d42a0f |
||
|
2cd01eb1fe | ||
|
07378f665a | ||
|
bfd5f24f4c | ||
|
3d617187af |
||
|
d040b93ca9 | ||
|
a1d460385d |
||
|
a410e2962a | ||
|
f5aa8f37bb | ||
|
7e932df450 | ||
|
3d4741727e | ||
|
a03b63ef74 | ||
|
15ce3e9f20 | ||
|
d2ed0c65ca |
||
|
da58b72f9d |
||
|
1639bd7af1 | ||
|
d474121f85 |
||
|
978f76ffb6 |
||
|
084bac00f5 |
||
|
94454172dd |
||
|
891d3cf966 | ||
|
561d5ec7ab | ||
|
7ce437d50a | ||
|
4b02d4ce90 | ||
|
3107185869 | ||
|
2e3584a353 | ||
|
e5b1be195c | ||
|
dde30c9d76 | ||
|
3830e65de8 | ||
|
c589cf167e | ||
|
2fde367c82 | ||
|
8fd188268e | ||
|
b65257df42 |
||
|
aaa2d7f08d | ||
|
f73e25ece6 | ||
|
78d427f208 | ||
|
eaeaf3538f | ||
|
85e381a85e | ||
|
1b7ee8231b |
||
|
1b8b8f5738 | ||
|
53df19b477 |
||
|
ccf21b7580 | ||
|
4189d62a57 |
||
|
9a3e3af614 | ||
|
f7187400dc | ||
|
f55a7f0a7b | ||
|
d6d35a645e |
||
|
e719dcc7f5 |
||
|
bc5bc5450c | ||
|
f4bade0c2e |
||
|
9be59c674d | ||
|
a1dec23c20 | ||
|
ed926c4e37 |
||
|
ab360ed6f6 | ||
|
569ba3d651 |
||
|
60fe28c2fe |
||
|
2787e29a07 | ||
|
c77a4d08d6 |
||
|
9b3f90f922 | ||
|
c88d457021 |
||
|
b20b625820 | ||
|
fd95311920 | ||
|
6da5c11731 | ||
|
4e58231308 | ||
|
ef0ecf249a |
||
|
4981617f7a | ||
|
2070bc7007 | ||
|
231d2461b3 | ||
|
3b457f87c4 | ||
|
de3ced4d3c | ||
|
891777e89e | ||
|
287239dd1c | ||
|
7cdded8fd7 | ||
|
8c9d045e1d | ||
|
620f5a0459 |
||
|
178d874ba0 | ||
|
d44f30c8a6 | ||
|
ce66937429 | ||
|
9823337375 | ||
|
11f5f0dfe1 | ||
|
e1882f19e8 | ||
|
6a8b9f06c2 | ||
|
752fc8787d | ||
|
90a1cd8280 | ||
|
aa570ac29d | ||
|
fb7b6363f9 |
||
|
23afe7994c |
||
|
7557e6f6ba |
||
|
86b6938911 |
||
|
8f30a45fa8 |
||
|
7c9e9d5f52 | ||
|
4066ce73a8 | ||
|
b5722dba1a | ||
|
81765ecafc | ||
|
84b42e9d19 | ||
|
ed319a0e5f | ||
|
dd55d10194 | ||
|
2084b46090 | ||
|
53443a6cf2 | ||
|
92715b5642 | ||
|
6166392515 | ||
|
49d0dead7d | ||
|
6f004830ff | ||
|
e2e5e36bad | ||
|
f267d264d3 | ||
|
be1a77bfd7 | ||
|
41a980e826 |
||
|
09c09f3d64 |
||
|
2404399ec5 | ||
|
b45d4c0557 | ||
|
a41b138d3c |
||
|
1e46949dd6 |
||
|
3ed2c1ba5d | ||
|
809b99c9c9 |
||
|
4d3acdb5fb | ||
|
ca9e321ef2 |
||
|
da27517fcf | ||
|
192df0a3b8 | ||
|
a965003a9d | ||
|
9ea26c821f | ||
|
14b699485a | ||
|
1684edc43f | ||
|
580c4418b9 | ||
|
4a65fc2358 |
||
|
71ba131fb3 |
||
|
9693b50719 |
||
|
102e2c54bb | ||
|
e989590c08 | ||
|
6cee33b449 | ||
|
f32498a444 | ||
|
c85f71b601 |
||
|
196e55899e | ||
|
ebec45076d | ||
|
561d9ae987 | ||
|
8950bd94cb | ||
|
f416f197bc | ||
|
65afe5a0e6 | ||
|
4b5d347413 | ||
|
4dcc2dd0ca |
||
|
2a7a332160 | ||
|
27ee1eabda | ||
|
0034665965 | ||
|
a69692be18 | ||
|
dc76152166 | ||
|
d7f3ae696c | ||
|
71f5449d34 | ||
|
0e64fa8d4c | ||
|
73b048d4c5 | ||
|
1c05b39861 | ||
|
7cfa6c163f | ||
|
2d4af2e867 | ||
|
1eeaffc442 | ||
|
82125b33ed | ||
|
42cbbc28fd | ||
|
a7cbb0e93c | ||
|
fde6148ece | ||
|
df1661d75a | ||
|
f938f79a35 | ||
|
333f00235b | ||
|
c06475bfb3 | ||
|
d1a54d0cf3 | ||
|
349437c06b | ||
|
1b03c83c84 |
||
|
bb749aacf1 | ||
|
3a41b89e52 | ||
|
70cbc77381 | ||
|
3a99f5dfaa | ||
|
f24435ecf4 | ||
|
4a708e316a | ||
|
c2b47c998d | ||
|
534f7b3134 | ||
|
d5d2692317 | ||
|
dc9cc7b00f | ||
|
965e74c7e2 | ||
|
096ba54eb1 | ||
|
f4e38f9e50 | ||
|
c0d9409176 | ||
|
7d1f565749 | ||
|
dfec4ada3b | ||
|
cd695cf265 | ||
|
47ff2e0c38 | ||
|
db7c09291f | ||
|
01f10c49ba |
||
|
1ff0692a72 |
||
|
116e6099d5 |
||
|
18ccaadc5b |
||
|
8f6eac7ca2 |
||
|
f4610d0df5 |
||
|
bf1a6b7d0a |
||
|
b3fd05e62e |
||
|
f7ce365618 |
||
|
77a558dbe5 |
||
|
cc0c400b28 |
||
|
2bcd59cbfa | ||
|
5139acc7f1 | ||
|
1564433e02 |
||
|
1339beb7cd |
||
|
cd9698ea48 |
||
|
c8f8e4c5eb |
||
|
0b4ab46563 | ||
|
ea1ac86134 | ||
|
790331e798 | ||
|
f5d9b2ba41 | ||
|
7f26ac00b1 | ||
|
fcbab10434 | ||
|
c4061cc6ac | ||
|
12ac4d6b6f | ||
|
3d06e62cd4 | ||
|
d7d23e1048 | ||
|
1fe9b70176 | ||
|
a9cf8dd71a |
||
|
3299261db3 |
||
|
e465ec8278 |
||
|
d0e4a0aa1f |
||
|
74efec3235 |
||
|
13516087f2 |
||
|
0a0c16524a | ||
|
9b843a155e | ||
|
cb085acbff | ||
|
c3d7df166b | ||
|
d312062125 | ||
|
e2453192aa | ||
|
68eb0cc8f2 |
||
|
cb9cecfa5d |
||
|
0f4e4a7d97 | ||
|
f20a708b36 | ||
|
8c4e511883 | ||
|
a4a3b8d664 | ||
|
bf6530ea81 | ||
|
4a80c2aab1 | ||
|
527bbfe43f | ||
|
d8e1edb60b | ||
|
245b5f74c0 | ||
|
e9a1f63415 | ||
|
ec370dd94b | ||
|
e39d862ef3 | ||
|
7b065654aa | ||
|
918b2bbe96 | ||
|
e529a3d34d | ||
|
5475778d67 | ||
|
c6a3ff0a53 | ||
|
cf3587f504 | ||
|
d42f104884 | ||
|
6a43568369 | ||
|
85c9cd0a6e | ||
|
be5920cfae | ||
|
3d25d94a77 | ||
|
fe97850835 | ||
|
dab9decd89 | ||
|
854651aa71 | ||
|
fdd1af3287 | ||
|
0bf92b6aff | ||
|
d9403bf4da | ||
|
716d8caf4d | ||
|
0f0f368a75 | ||
|
ff8d7558d4 | ||
|
66f9824b68 | ||
|
44a6e5da38 | ||
|
de5a4aa5f3 | ||
|
e8007082a7 | ||
|
3c70c5a366 | ||
|
eb6e79b055 | ||
|
ea59f8dccb | ||
|
aef1c584e5 | ||
|
c4ce671a87 | ||
|
e8a79c87ab | ||
|
249e77a5d3 | ||
|
3cf4a52a69 | ||
|
eb8b02756b | ||
|
0510d34ed3 | ||
|
1c8d12e72a | ||
|
0a36a6b674 | ||
|
b887c9d50f | ||
|
ee4e108e4f | ||
|
5e14a0fed4 | ||
|
6045205ea9 | ||
|
f2d763cdec | ||
|
e5e348205a | ||
|
af6d219936 | ||
|
82a07e2e09 | ||
|
12a9b99fff | ||
|
3adf761158 | ||
|
670a4c61ff | ||
|
220f50d3bb | ||
|
e0bf9d2a7c | ||
|
f61cf46a52 | ||
|
d188128d27 | ||
|
f698c4120d | ||
|
338a852d49 | ||
|
a64ee2242c | ||
|
e9ff5e6f0b | ||
|
f3911d8b68 | ||
|
9ce0be6450 | ||
|
6ab3eff61c | ||
|
0281da1c5a | ||
|
0b4770188c | ||
|
9376bb05fa | ||
|
ecca3b6793 | ||
|
f41a971cd8 | ||
|
44ba66d619 | ||
|
bf685a607f | ||
|
5713cf0508 | ||
|
bdd50d70ca | ||
|
8188399ce6 | ||
|
f72b7dbbbb | ||
|
2409afcc5c | ||
|
15c0d02c13 | ||
|
a54a5081e6 | ||
|
db9dfcf049 | ||
|
47f9948748 | ||
|
05e866df55 | ||
|
fc431f0cb8 | ||
|
228ab359ed |
||
|
103a8587f7 |
||
|
7db0083928 | ||
|
e6f6ab499a | ||
|
721b7dbba0 | ||
|
a95ddab814 | ||
|
2941546ae4 | ||
|
bd9b9179c1 |
||
|
ce7d54c151 |
||
|
3c778c07c2 |
||
|
95207341db |
||
|
70cf24924d |
||
|
a8ebba691e |
||
|
ec19ea44ad | ||
|
ca8dc0f0f5 | ||
|
1dc50a697c | ||
|
1167c314ee | ||
|
55781e2b34 | ||
|
7439e44e44 | ||
|
cf2639df3d | ||
|
834de928c2 | ||
|
72efb21439 | ||
|
aa8790ebdb | ||
|
6d491052ee | ||
|
87ff4691ce | ||
|
34d76e79ed | ||
|
31b43da96f | ||
|
0540e673e2 | ||
|
4e88a63809 | ||
|
f7581f8a65 | ||
|
e87a1c079c | ||
|
3f9477c246 | ||
|
05ed1e188e | ||
|
f3d06e49f8 | ||
|
f9a4b68967 | ||
|
3631cfe365 |
||
|
da6eef905c |
||
|
8766ae176e | ||
|
36b53d490f | ||
|
f9b8b812a4 | ||
|
ac9eae5272 | ||
|
f270cc00d8 | ||
|
a5a3f970da | ||
|
987c465bf8 | ||
|
cf3c766fd9 | ||
|
7efafae432 | ||
|
1b8f44dde3 | ||
|
4d93246863 | ||
|
0471886d9f | ||
|
266974b799 | ||
|
c3663c67d7 | ||
|
07bb23d10b |
||
|
749fc22c6b |
||
|
9f9a4e8298 |
||
|
39e7d64d3f |
||
|
35d8610c00 | ||
|
bc550ae8f5 | ||
|
c76ef7f19b | ||
|
b7781264d3 |
||
|
696e03941a |
||
|
4609a351dc |
||
|
c275415a49 |
656 changed files with 41883 additions and 5925 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
166
.github/ISSUE_TEMPLATE/1-bug_report.yml
vendored
Normal file
166
.github/ISSUE_TEMPLATE/1-bug_report.yml
vendored
Normal file
|
@ -0,0 +1,166 @@
|
|||
name: Bug Report
|
||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||
labels: ["Bug", "Android"]
|
||||
title: "Bug: "
|
||||
type: bug
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for taking the time to fill out this bug report.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
## Filing a bug report
|
||||
|
||||
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
||||
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
|
||||
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||
placeholder: |
|
||||
0. Play a Youtube video
|
||||
1. Press on Download button
|
||||
2. Select quality 1440p
|
||||
3. Grayjay crashes when attempting to download
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-result
|
||||
attributes:
|
||||
label: Actual result
|
||||
description: What happend?
|
||||
placeholder: Tell us what you saw!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-result
|
||||
attributes:
|
||||
label: Expected result
|
||||
description: What was suppose to happen?
|
||||
placeholder: Tell us what you expected to happen!
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: grayjay-version
|
||||
attributes:
|
||||
label: Grayjay Version
|
||||
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
||||
placeholder: "311"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: plugin
|
||||
attributes:
|
||||
label: What plugins are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- "All"
|
||||
- "Apple Podcasts"
|
||||
- "BiliBili (CN)"
|
||||
- "Bitchute"
|
||||
- "Crunchyroll"
|
||||
- "CuriosityStream"
|
||||
- "Dailymotion"
|
||||
- "Kick"
|
||||
- "Nebula"
|
||||
- "Odysee"
|
||||
- "Patreon"
|
||||
- "PeerTube"
|
||||
- "Rumble"
|
||||
- "SoundCloud"
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "Youtube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: Plugin Version
|
||||
description: In the application, select Sources > [the broken plugin], write down the value under "Version".
|
||||
placeholder: "12"
|
||||
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Which android version are you using?
|
||||
placeholder: "Android 15"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Which device are you using?
|
||||
placeholder: "Google Pixel 9"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Which operating system are you using?
|
||||
placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: login
|
||||
attributes:
|
||||
label: When do you experience the issue?
|
||||
options:
|
||||
- label: While logged in
|
||||
- label: While logged out
|
||||
- label: N/A
|
||||
|
||||
- type: dropdown
|
||||
id: vpn
|
||||
attributes:
|
||||
label: Are you using a VPN?
|
||||
multiple: false
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
|
||||
```
|
||||
- #10
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
59
.github/ISSUE_TEMPLATE/2-feature_request.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/2-feature_request.yml
vendored
Normal file
|
@ -0,0 +1,59 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or other enhancement.
|
||||
labels: ["Enhancement", "Android"]
|
||||
title: "Feature request: "
|
||||
type: feature
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a feature request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
|
||||
|
||||
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-use-case
|
||||
attributes:
|
||||
label: Use Cases
|
||||
description: |
|
||||
In order to properly evaluate a feature request, it is necessary to understand the use cases for it. Please describe below the _end goal_ you are trying to achieve that has led you to request this feature. Please keep this section focused on the problem and not on the suggested solution.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: |
|
||||
If you have an idea for a way to address the problem via a change to Grayjay features, please describe it below.
|
||||
|
||||
In this section, it's helpful to include specific examples of how what you are suggesting might look in the application, this allows us to understand the full picture of what you are proposing. If you're not sure of some details, don't worry! When we evaluate the feature request we may suggest modifications as necessary to work within the design constraints of the Grayjay Core Application.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above or to the suggested solution? If so, please create a list below that mentions each of them. For example:
|
||||
```
|
||||
- #10
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||
|
66
.github/ISSUE_TEMPLATE/3-documentation_issue.yml
vendored
Normal file
66
.github/ISSUE_TEMPLATE/3-documentation_issue.yml
vendored
Normal file
|
@ -0,0 +1,66 @@
|
|||
name: Documentation Issue
|
||||
description: Report an issue or suggest a change in the documentation.
|
||||
labels: ["Documentation"]
|
||||
title: "Documentation: "
|
||||
type: task
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Thank you for opening a documentation change request.
|
||||
|
||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
||||
|
||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-affected-pages
|
||||
attributes:
|
||||
label: Affected Pages
|
||||
description: |
|
||||
Link to or describe the pages relevant to your documentation change request.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-problem
|
||||
attributes:
|
||||
label: What is the docs issue?
|
||||
description: What problems or suggestions do you have about the documentation?
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: What documentation changes would fix this issue and where would you expect to find them? Are one or more page headings unclear? Do one or more pages need additional context, examples, or warnings? Do we need a new page or section dedicated to a specific topic? Your ideas help us understand what you and other users need from our documentation and how we can improve the content.
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: grayjay-references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Are there any other open or closed GitLab/GitHub issues related to the problem or solution you described? If so, list them below. For example:
|
||||
```
|
||||
- #6017
|
||||
```
|
||||
placeholder:
|
||||
value:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Need a Grayjay License?
|
||||
url: https://pay.futo.org/api/PaymentPortal
|
||||
about: Purchase a Grayjay license with FutoPay
|
||||
- name: Plugin Building, Usage, or other Questions
|
||||
url: https://chat.futo.org/#narrow/stream/46-Grayjay
|
||||
about: Grayjays Community Chat
|
36
.gitmodules
vendored
36
.gitmodules
vendored
|
@ -70,3 +70,39 @@
|
|||
[submodule "app/src/unstable/assets/sources/spotify"]
|
||||
path = app/src/unstable/assets/sources/spotify
|
||||
url = ../plugins/spotify.git
|
||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
[submodule "app/src/unstable/assets/sources/bitchute"]
|
||||
path = app/src/unstable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
[submodule "app/src/unstable/assets/sources/dailymotion"]
|
||||
path = app/src/unstable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
[submodule "app/src/stable/assets/sources/dailymotion"]
|
||||
path = app/src/stable/assets/sources/dailymotion
|
||||
url = ../plugins/dailymotion.git
|
||||
[submodule "app/src/stable/assets/sources/apple-podcast"]
|
||||
path = app/src/stable/assets/sources/apple-podcasts
|
||||
url = ../plugins/apple-podcasts.git
|
||||
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||
path = app/src/unstable/assets/sources/apple-podcasts
|
||||
url = ../plugins/apple-podcasts.git
|
||||
[submodule "app/src/stable/assets/sources/tedtalks"]
|
||||
path = app/src/stable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||
path = app/src/unstable/assets/sources/tedtalks
|
||||
url = ../plugins/tedtalks.git
|
||||
[submodule "app/src/stable/assets/sources/curiositystream"]
|
||||
path = app/src/stable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[submodule "app/src/unstable/assets/sources/curiositystream"]
|
||||
path = app/src/unstable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[submodule "app/src/unstable/assets/sources/crunchyroll"]
|
||||
path = app/src/unstable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||
path = app/src/stable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
|
|
|
@ -49,9 +49,23 @@ We encourage developers to write their own plugins. Please refer to the "Getting
|
|||
|
||||
## Contributing to Core
|
||||
|
||||
**We are currently not accepting contributions to the core.**
|
||||
|
||||
The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
|
||||
### License
|
||||
|
||||
The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
|
||||
|
||||
### How to Contribute
|
||||
|
||||
1. Fork the core repository.
|
||||
2. Clone your fork.
|
||||
3. Make your changes.
|
||||
4. Commit and push your changes.
|
||||
5. Open a pull request.
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Ensure your code adheres to the existing style.
|
||||
- Include documentation and unit tests (where applicable).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Grayjay Core License 1.0
|
||||
# Source First License 1.1
|
||||
|
||||
## Acceptance
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
@ -16,7 +16,7 @@ Notwithstanding the above, you may not remove or obscure any functionality in th
|
|||
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law.
|
||||
|
||||
## Patents
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your license for the software granted under these terms ends immediately. If your company makes such a claim, your license ends immediately for work on behalf of your company.
|
||||
|
||||
## Notices
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
|
||||
|
|
35
README.md
35
README.md
|
@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/video.png" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/video-details.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Video</td>
|
||||
|
@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sources (all enabled)</td>
|
||||
<td>Sources (one disabled)</td>
|
||||
<td>Sources</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
|
|||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Install a new source</td>
|
||||
|
@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/search-list.png" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/search-preview.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Search (list)</td>
|
||||
|
@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/channel.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Channel</td>
|
||||
|
@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/settings.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Settings</td>
|
||||
|
@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/playlists.png" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/playlist.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Playlists</td>
|
||||
|
@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/downloads.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Downloads</td>
|
||||
|
@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
|
|||
|
||||
<table border="0">
|
||||
<tr>
|
||||
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td>
|
||||
<td><b style="font-size:30px"><img src="images/casting.png" height="700" /></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Casting</td>
|
||||
|
@ -182,6 +180,12 @@ In the future we hope to offer users the choice of their desired recommendation
|
|||
|
||||
1. Download a copy of the repository.
|
||||
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
|
||||
3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
|
||||
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
|
||||
|
||||
|
@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
|
|||
|
||||
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).
|
||||
|
|
BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
(Stored with Git LFS)
Normal file
BIN
app/aar/ffmpeg-kit-full-6.0-2.LTS.aar
(Stored with Git LFS)
Normal file
Binary file not shown.
|
@ -2,7 +2,7 @@ plugins {
|
|||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.google.devtools.ksp'
|
||||
|
@ -144,9 +144,20 @@ android {
|
|||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
assets {
|
||||
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
|
@ -170,6 +181,7 @@ dependencies {
|
|||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
|
@ -184,11 +196,11 @@ dependencies {
|
|||
implementation 'androidx.media:media:1.7.0'
|
||||
|
||||
//Other
|
||||
implementation 'org.jmdns:jmdns:3.5.1'
|
||||
implementation 'org.jsoup:jsoup:1.15.3'
|
||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'com.arthenica:ffmpeg-kit-full:5.1'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import toAndroidColor
|
||||
|
||||
class CSSColorTests {
|
||||
@Test
|
||||
fun test1() {
|
||||
val androidHex = "#80336699"
|
||||
val androidColorInt = Color.parseColor(androidHex)
|
||||
|
||||
val cssHex = "#33669980"
|
||||
val cssColor = CSSColor.parseColor(cssHex)
|
||||
|
||||
assertEquals(
|
||||
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||
androidColorInt,
|
||||
cssColor.toAndroidColor(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test2() {
|
||||
val androidHex = "#123ABC"
|
||||
val androidColorInt = Color.parseColor(androidHex)
|
||||
|
||||
val cssHex = "#123ABCFF"
|
||||
val cssColor = CSSColor.parseColor(cssHex)
|
||||
|
||||
assertEquals(
|
||||
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||
androidColorInt,
|
||||
cssColor.toAndroidColor()
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,338 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.sync.internal.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.selects.select
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
/*
|
||||
class SyncServerTests {
|
||||
|
||||
//private val relayHost = "relay.grayjay.app"
|
||||
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
|
||||
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
|
||||
private val relayHost = "192.168.1.138"
|
||||
private val relayPort = 9000
|
||||
|
||||
/** Creates a client connected to the live relay server. */
|
||||
private suspend fun createClient(
|
||||
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
|
||||
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
|
||||
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
|
||||
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
|
||||
onException: ((Throwable) -> Unit)? = null
|
||||
): SyncSocketSession = withContext(Dispatchers.IO) {
|
||||
val p = Noise.createDH("25519")
|
||||
p.generateKeyPair()
|
||||
val socket = Socket(relayHost, relayPort)
|
||||
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
|
||||
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
|
||||
val tcs = CompletableDeferred<Boolean>()
|
||||
val socketSession = SyncSocketSession(
|
||||
relayHost,
|
||||
p,
|
||||
inputStream,
|
||||
outputStream,
|
||||
onClose = { socket.close() },
|
||||
onHandshakeComplete = { s ->
|
||||
onHandshakeComplete?.invoke(s)
|
||||
tcs.complete(true)
|
||||
},
|
||||
onData = onData ?: { _, _, _, _ -> },
|
||||
onNewChannel = onNewChannel ?: { _, _ -> },
|
||||
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
|
||||
)
|
||||
socketSession.authorizable = AlwaysAuthorized()
|
||||
try {
|
||||
socketSession.startAsInitiator(relayKey)
|
||||
} catch (e: Throwable) {
|
||||
onException?.invoke(e)
|
||||
}
|
||||
withTimeout(5000.milliseconds) { tcs.await() }
|
||||
return@withContext socketSession
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleClientsHandshake_Success() = runBlocking {
|
||||
val client1 = createClient()
|
||||
val client2 = createClient()
|
||||
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
|
||||
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
|
||||
client1.stop()
|
||||
client2.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val clientC = createClient()
|
||||
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
|
||||
delay(100.milliseconds)
|
||||
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
|
||||
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
|
||||
assertNotNull("Client B should receive connection info", infoB)
|
||||
assertEquals(12345.toUShort(), infoB!!.port)
|
||||
assertNull("Client C should not receive connection info (unauthorized)", infoC)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
clientC.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_Bidirectional_Success() = runBlocking {
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
|
||||
|
||||
val tcsDataA = CompletableDeferred<ByteArray>()
|
||||
channelA.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
|
||||
}
|
||||
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
|
||||
|
||||
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
|
||||
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
|
||||
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
|
||||
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
|
||||
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||
assertArrayEquals(maxSizeData, receivedData)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndGetRecord_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val clientC = createClient()
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
|
||||
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
|
||||
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
|
||||
assertTrue(success)
|
||||
assertNotNull(recordB)
|
||||
assertArrayEquals(data, recordB!!.first)
|
||||
assertNull("Unauthorized client should not access record", recordC)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
clientC.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNonExistentRecord_ReturnsNull() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
|
||||
assertNull("Getting non-existent record should return null", record)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateRecord_TimestampUpdated() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val key = "updateKey"
|
||||
val data1 = byteArrayOf(1)
|
||||
val data2 = byteArrayOf(2)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
|
||||
val record1 = clientB.getRecord(clientA.localPublicKey, key)
|
||||
delay(1000.milliseconds)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
|
||||
val record2 = clientB.getRecord(clientA.localPublicKey, key)
|
||||
assertNotNull(record1)
|
||||
assertNotNull(record2)
|
||||
assertTrue(record2!!.second > record1!!.second)
|
||||
assertArrayEquals(data2, record2.first)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRecord_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
|
||||
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
|
||||
assertTrue(success)
|
||||
assertNull(record)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listRecordKeys_Success() = runBlocking {
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val keys = arrayOf("key1", "key2", "key3")
|
||||
keys.forEach { key ->
|
||||
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
|
||||
}
|
||||
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
|
||||
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
|
||||
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
|
||||
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||
channelA.authorizable = AlwaysAuthorized()
|
||||
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||
channelB.authorizable = AlwaysAuthorized()
|
||||
channelTask.await()
|
||||
|
||||
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||
channelB.setDataHandler { _, _, o, so, d ->
|
||||
val b = ByteArray(d.remaining())
|
||||
d.get(b)
|
||||
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||
}
|
||||
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
|
||||
assertArrayEquals(largeData, receivedData)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publishAndGetLargeRecord_Success() = runBlocking {
|
||||
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
|
||||
val clientA = createClient()
|
||||
val clientB = createClient()
|
||||
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
|
||||
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
|
||||
assertTrue(success)
|
||||
assertNotNull(record)
|
||||
assertArrayEquals(largeData, record!!.first)
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_WithValidAppId_Success() = runBlocking {
|
||||
// Arrange: Set up clients
|
||||
val allowedAppId = 1234u
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
|
||||
// Client B requires appId 1234
|
||||
val clientB = createClient(
|
||||
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
|
||||
)
|
||||
|
||||
val clientA = createClient()
|
||||
|
||||
// Act: Start relayed channel with valid appId
|
||||
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
|
||||
val channelB = withTimeout(5.seconds) { tcsB.await() }
|
||||
withTimeout(5.seconds) { channelTask.await() }
|
||||
|
||||
// Assert: Channel is established
|
||||
assertNotNull("Channel should be created on target with valid appId", channelB)
|
||||
|
||||
// Clean up
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
|
||||
// Arrange: Set up clients
|
||||
val allowedAppId = 1234u
|
||||
val invalidAppId = 5678u
|
||||
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||
|
||||
// Client B requires appId 1234
|
||||
val clientB = createClient(
|
||||
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
|
||||
onException = { }
|
||||
)
|
||||
|
||||
val clientA = createClient()
|
||||
|
||||
// Act & Assert: Attempt with invalid appId should fail
|
||||
try {
|
||||
withTimeout(5.seconds) {
|
||||
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
|
||||
}
|
||||
fail("Starting relayed channel with invalid appId should fail")
|
||||
} catch (e: Throwable) {
|
||||
// Expected: The channel creation should time out or fail
|
||||
}
|
||||
|
||||
// Ensure no channel was created on client B
|
||||
val completedTask = select {
|
||||
tcsB.onAwait { "channel" }
|
||||
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
|
||||
}
|
||||
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
|
||||
|
||||
// Clean up
|
||||
clientA.stop()
|
||||
clientB.stop()
|
||||
}
|
||||
}
|
||||
|
||||
class AlwaysAuthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean get() = true
|
||||
}*/
|
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal file
512
app/src/androidTest/java/com/futo/platformplayer/SyncTests.kt
Normal file
|
@ -0,0 +1,512 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.noise.protocol.DHState
|
||||
import com.futo.platformplayer.noise.protocol.Noise
|
||||
import com.futo.platformplayer.sync.internal.*
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.PipedInputStream
|
||||
import java.io.PipedOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.random.Random
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
/*
|
||||
data class PipeStreams(
|
||||
val initiatorInput: LittleEndianDataInputStream,
|
||||
val initiatorOutput: LittleEndianDataOutputStream,
|
||||
val responderInput: LittleEndianDataInputStream,
|
||||
val responderOutput: LittleEndianDataOutputStream
|
||||
)
|
||||
|
||||
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
|
||||
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
|
||||
typealias OnClose = (SyncSocketSession) -> Unit
|
||||
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
|
||||
|
||||
class SyncSocketTests {
|
||||
private fun createPipeStreams(): PipeStreams {
|
||||
val initiatorOutput = PipedOutputStream()
|
||||
val responderOutput = PipedOutputStream()
|
||||
val responderInput = PipedInputStream(initiatorOutput)
|
||||
val initiatorInput = PipedInputStream(responderOutput)
|
||||
return PipeStreams(
|
||||
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
|
||||
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
|
||||
)
|
||||
}
|
||||
|
||||
fun generateKeyPair(): DHState {
|
||||
val p = Noise.createDH("25519")
|
||||
p.generateKeyPair()
|
||||
return p
|
||||
}
|
||||
|
||||
private fun createSessions(
|
||||
initiatorInput: LittleEndianDataInputStream,
|
||||
initiatorOutput: LittleEndianDataOutputStream,
|
||||
responderInput: LittleEndianDataInputStream,
|
||||
responderOutput: LittleEndianDataOutputStream,
|
||||
initiatorKeyPair: DHState,
|
||||
responderKeyPair: DHState,
|
||||
onInitiatorHandshakeComplete: OnHandshakeComplete,
|
||||
onResponderHandshakeComplete: OnHandshakeComplete,
|
||||
onInitiatorClose: OnClose? = null,
|
||||
onResponderClose: OnClose? = null,
|
||||
onClose: OnClose? = null,
|
||||
isHandshakeAllowed: IsHandshakeAllowed? = null,
|
||||
onDataA: OnData? = null,
|
||||
onDataB: OnData? = null
|
||||
): Pair<SyncSocketSession, SyncSocketSession> {
|
||||
val initiatorSession = SyncSocketSession(
|
||||
"", initiatorKeyPair, initiatorInput, initiatorOutput,
|
||||
onClose = {
|
||||
onClose?.invoke(it)
|
||||
onInitiatorClose?.invoke(it)
|
||||
},
|
||||
onHandshakeComplete = onInitiatorHandshakeComplete,
|
||||
onData = onDataA,
|
||||
isHandshakeAllowed = isHandshakeAllowed
|
||||
)
|
||||
|
||||
val responderSession = SyncSocketSession(
|
||||
"", responderKeyPair, responderInput, responderOutput,
|
||||
onClose = {
|
||||
onClose?.invoke(it)
|
||||
onResponderClose?.invoke(it)
|
||||
},
|
||||
onHandshakeComplete = onResponderHandshakeComplete,
|
||||
onData = onDataB,
|
||||
isHandshakeAllowed = isHandshakeAllowed
|
||||
)
|
||||
|
||||
return Pair(initiatorSession, responderSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
val invalidPairingCode = "wrong"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(100.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val validPairingCode = "secret"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val pairingCode = "unnecessary"
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val smallData = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(smallData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(maxData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stream_LargeData_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Ensure both sessions are authorized
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(largeData, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorizedSession_CanSendData() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, opcode, subOpcode, data ->
|
||||
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||
val b = ByteArray(data.remaining())
|
||||
data.get(b)
|
||||
tcsDataReceived.complete(b)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Authorize both sessions
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Authorized()
|
||||
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||
|
||||
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||
assertArrayEquals(data, receivedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unauthorizedSession_CannotSendData() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onDataB = { _, _, _, _ -> }
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(10.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
// Authorize initiator but not responder
|
||||
initiatorSession.authorizable = Authorized()
|
||||
responderSession.authorizable = Unauthorized()
|
||||
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||
|
||||
delay(1.seconds)
|
||||
assertFalse(tcsDataReceived.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val allowedAppId = 1234u
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
|
||||
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||
linkType == LinkType.Direct && appId == allowedAppId
|
||||
}
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
handshakeInitiatorCompleted.await()
|
||||
handshakeResponderCompleted.await()
|
||||
}
|
||||
|
||||
assertNotNull(initiatorSession.remotePublicKey)
|
||||
assertNotNull(responderSession.remotePublicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
|
||||
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||
val initiatorKeyPair = generateKeyPair()
|
||||
val responderKeyPair = generateKeyPair()
|
||||
val allowedAppId = 1234u
|
||||
val invalidAppId = 5678u
|
||||
|
||||
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||
val responderClosed = CompletableDeferred<Boolean>()
|
||||
|
||||
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||
linkType == LinkType.Direct && appId == allowedAppId
|
||||
}
|
||||
|
||||
val (initiatorSession, responderSession) = createSessions(
|
||||
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||
initiatorKeyPair, responderKeyPair,
|
||||
{ handshakeInitiatorCompleted.complete(true) },
|
||||
{ handshakeResponderCompleted.complete(true) },
|
||||
onInitiatorClose = {
|
||||
initiatorClosed.complete(true)
|
||||
},
|
||||
onResponderClose = {
|
||||
responderClosed.complete(true)
|
||||
},
|
||||
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||
)
|
||||
|
||||
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
|
||||
responderSession.startAsResponder()
|
||||
|
||||
withTimeout(5.seconds) {
|
||||
initiatorClosed.await()
|
||||
responderClosed.await()
|
||||
}
|
||||
|
||||
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
class Authorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean = true
|
||||
}
|
||||
|
||||
class Unauthorized : IAuthorizable {
|
||||
override val isAuthorized: Boolean = false
|
||||
}*/
|
|
@ -11,6 +11,7 @@
|
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||
<!--<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||
|
@ -35,15 +36,18 @@
|
|||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver android:name=".receivers.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service android:name=".services.MediaPlaybackService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
<service android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name=".services.ExportingService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<receiver android:name=".receivers.MediaControlReceiver" />
|
||||
<receiver android:name=".receivers.AudioNoisyReceiver" />
|
||||
|
@ -51,11 +55,10 @@
|
|||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:launchMode="singleInstance"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
|
||||
|
@ -148,34 +151,30 @@
|
|||
<data android:scheme="polycentric" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".activities.TestActivity"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.DeveloperActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ExceptionActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.CaptchaActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.AddSourceActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
||||
<intent-filter>
|
||||
|
@ -189,44 +188,55 @@
|
|||
</activity>
|
||||
<activity
|
||||
android:name=".activities.AddSourceOptionsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricHomeActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricBackupActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricCreateProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricWhyActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricImportProfileActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.ManageTabsActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".activities.QRCaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.FCastGuideActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncHomeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncPairActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SyncShowPairingCodeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
|
11
app/src/main/assets/scripts/JSDOM.js
Normal file
11
app/src/main/assets/scripts/JSDOM.js
Normal file
File diff suppressed because one or more lines are too long
|
@ -11,7 +11,8 @@ let Type = {
|
|||
Streams: "STREAMS",
|
||||
Mixed: "MIXED",
|
||||
Live: "LIVE",
|
||||
Subscriptions: "SUBSCRIPTIONS"
|
||||
Subscriptions: "SUBSCRIPTIONS",
|
||||
Shorts: "SHORTS"
|
||||
},
|
||||
Order: {
|
||||
Chronological: "CHRONOLOGICAL"
|
||||
|
@ -31,7 +32,8 @@ let Type = {
|
|||
Text: {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
MARKUP: 2,
|
||||
CODE: 3
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
@ -101,6 +103,12 @@ class UnavailableException extends ScriptException {
|
|||
super("UnavailableException", msg);
|
||||
}
|
||||
}
|
||||
class ReloadRequiredException extends ScriptException {
|
||||
constructor(msg, reloadData) {
|
||||
super("ReloadRequiredException", msg);
|
||||
this.reloadData = reloadData;
|
||||
}
|
||||
}
|
||||
class AgeException extends ScriptException {
|
||||
constructor(msg) {
|
||||
super("AgeException", msg);
|
||||
|
@ -201,7 +209,7 @@ class PlatformContent {
|
|||
obj = obj ?? {};
|
||||
this.id = obj.id ?? PlatformID(); //PlatformID
|
||||
this.name = obj.name ?? ""; //string
|
||||
this.thumbnails = obj.thumbnails; //Thumbnail[]
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]); //Thumbnail[]
|
||||
this.author = obj.author; //PlatformAuthorLink
|
||||
this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long)
|
||||
this.url = obj.url ?? ""; //String
|
||||
|
@ -243,7 +251,11 @@ class PlatformVideo extends PlatformContent {
|
|||
this.duration = obj.duration ?? -1; //Long
|
||||
this.viewCount = obj.viewCount ?? -1; //Long
|
||||
|
||||
this.playbackTime = obj.playbackTime ?? -1;
|
||||
this.playbackDate = obj.playbackDate ?? undefined;
|
||||
|
||||
this.isLive = obj.isLive ?? false; //Boolean
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
}
|
||||
}
|
||||
class PlatformVideoDetails extends PlatformVideo {
|
||||
|
@ -260,6 +272,11 @@ class PlatformVideoDetails extends PlatformVideo {
|
|||
|
||||
this.rating = obj.rating ?? null; //IRating
|
||||
this.subtitles = obj.subtitles ?? [];
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
|
||||
if (obj.getContentRecommendations) {
|
||||
this.getContentRecommendations = obj.getContentRecommendations
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -278,12 +295,81 @@ class PlatformPostDetails extends PlatformPost {
|
|||
super(obj);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformPostDetails";
|
||||
this.rating = obj.rating ?? RatingLikes(-1);
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.textType = obj.textType ?? 0;
|
||||
this.content = obj.content ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformWeb extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWeb";
|
||||
}
|
||||
}
|
||||
class PlatformWebDetails extends PlatformWeb {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWebDetails";
|
||||
this.html = obj.html;
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformArticle extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticle";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class PlatformArticleDetails extends PlatformArticle {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticleDetails";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.segments = obj.segments ?? [];
|
||||
}
|
||||
}
|
||||
class ArticleSegment {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
class ArticleTextSegment extends ArticleSegment {
|
||||
constructor(content, textType) {
|
||||
super(1);
|
||||
this.textType = textType;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
class ArticleImagesSegment extends ArticleSegment {
|
||||
constructor(images, caption) {
|
||||
super(2);
|
||||
this.images = images;
|
||||
this.caption = caption;
|
||||
}
|
||||
}
|
||||
class ArticleHeaderSegment extends ArticleSegment {
|
||||
constructor(content, level) {
|
||||
super(3);
|
||||
this.level = level;
|
||||
this.content = content;
|
||||
}
|
||||
}
|
||||
class ArticleNestedSegment extends ArticleSegment {
|
||||
constructor(nested) {
|
||||
super(9);
|
||||
this.nested = nested;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Sources
|
||||
class VideoSourceDescriptor {
|
||||
constructor(obj) {
|
||||
|
@ -330,6 +416,16 @@ class VideoUrlSource {
|
|||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
this.plugin_type = "VideoUrlWidevineSource";
|
||||
|
||||
this.licenseUri = obj.licenseUri;
|
||||
if(obj.getLicenseRequestExecutor)
|
||||
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||
}
|
||||
}
|
||||
class VideoUrlRangeSource extends VideoUrlSource {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
|
@ -362,8 +458,26 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
|||
super(obj);
|
||||
this.plugin_type = "AudioUrlWidevineSource";
|
||||
|
||||
this.bearerToken = obj.bearerToken;
|
||||
this.licenseUri = obj.licenseUri;
|
||||
if(obj.getLicenseRequestExecutor)
|
||||
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||
|
||||
// deprecated api conversion
|
||||
if(obj.bearerToken) {
|
||||
this.getLicenseRequestExecutor = () => {
|
||||
return {
|
||||
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||
return http.POST(
|
||||
url,
|
||||
license_request_data,
|
||||
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||
false,
|
||||
true
|
||||
).body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class AudioUrlRangeSource extends AudioUrlSource {
|
||||
|
@ -406,6 +520,49 @@ class DashSource {
|
|||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
class DashWidevineSource extends DashSource {
|
||||
constructor(obj) {
|
||||
super(obj);
|
||||
this.plugin_type = "DashWidevineSource";
|
||||
|
||||
this.licenseUri = obj.licenseUri;
|
||||
if(obj.getLicenseRequestExecutor)
|
||||
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||
}
|
||||
}
|
||||
class DashManifestRawSource {
|
||||
constructor(obj) {
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "DashRawSource";
|
||||
this.name = obj.name ?? "";
|
||||
this.bitrate = obj.bitrate ?? 0;
|
||||
this.container = obj.container ?? "";
|
||||
this.codec = obj.codec ?? "";
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
|
||||
class DashManifestRawAudioSource {
|
||||
constructor(obj) {
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "DashRawAudioSource";
|
||||
this.name = obj.name ?? "";
|
||||
this.bitrate = obj.bitrate ?? 0;
|
||||
this.container = obj.container ?? "";
|
||||
this.codec = obj.codec ?? "";
|
||||
this.duration = obj.duration ?? 0;
|
||||
this.url = obj.url;
|
||||
this.language = obj.language ?? Language.UNKNOWN;
|
||||
this.manifest = obj.manifest ?? null;
|
||||
if(obj.requestModifier)
|
||||
this.requestModifier = obj.requestModifier;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RequestModifier {
|
||||
constructor(obj) {
|
||||
|
@ -436,7 +593,7 @@ class PlatformPlaylist extends PlatformContent {
|
|||
constructor(obj) {
|
||||
super(obj, 4);
|
||||
this.plugin_type = "PlatformPlaylist";
|
||||
this.videoCount = obj.videoCount ?? 0;
|
||||
this.videoCount = obj.videoCount ?? -1;
|
||||
this.thumbnail = obj.thumbnail;
|
||||
}
|
||||
}
|
||||
|
@ -480,6 +637,8 @@ class PlatformComment {
|
|||
this.date = obj.date ?? 0;
|
||||
this.replyCount = obj.replyCount ?? 0;
|
||||
this.context = obj.context ?? {};
|
||||
if(obj.getReplies)
|
||||
this.getReplies = obj.getReplies;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -551,11 +710,12 @@ class LiveEventViewCount extends LiveEvent {
|
|||
}
|
||||
}
|
||||
class LiveEventRaid extends LiveEvent {
|
||||
constructor(targetUrl, targetName, targetThumbnail) {
|
||||
constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
|
||||
super(100);
|
||||
this.targetUrl = targetUrl;
|
||||
this.targetName = targetName;
|
||||
this.targetThumbnail = targetThumbnail;
|
||||
this.isOutgoing = isOutgoing ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -628,6 +788,7 @@ let plugin = {
|
|||
//To override by plugin
|
||||
const source = {
|
||||
getHome() { return new ContentPager([], false, {}); },
|
||||
getShorts() { return new VideoPager([], false, {}); },
|
||||
|
||||
enable(config){ },
|
||||
disable() {},
|
||||
|
@ -762,3 +923,99 @@ class URLSearchParams {
|
|||
return searchString;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var __REGEX_SPACE_CHARACTERS = /<%= spaceCharacters %>/g;
|
||||
var __btoa_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
function btoa(input) {
|
||||
input = String(input);
|
||||
if (/[^\0-\xFF]/.test(input)) {
|
||||
// Note: no need to special-case astral symbols here, as surrogates are
|
||||
// matched, and the input is supposed to only contain ASCII anyway.
|
||||
error(
|
||||
'The string to be encoded contains characters outside of the ' +
|
||||
'Latin1 range.'
|
||||
);
|
||||
}
|
||||
var padding = input.length % 3;
|
||||
var output = '';
|
||||
var position = -1;
|
||||
var a;
|
||||
var b;
|
||||
var c;
|
||||
var buffer;
|
||||
// Make sure any padding is handled outside of the loop.
|
||||
var length = input.length - padding;
|
||||
|
||||
while (++position < length) {
|
||||
// Read three bytes, i.e. 24 bits.
|
||||
a = input.charCodeAt(position) << 16;
|
||||
b = input.charCodeAt(++position) << 8;
|
||||
c = input.charCodeAt(++position);
|
||||
buffer = a + b + c;
|
||||
// Turn the 24 bits into four chunks of 6 bits each, and append the
|
||||
// matching character for each of them to the output.
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 18 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer >> 12 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer >> 6 & 0x3F) +
|
||||
__btoa_TABLE.charAt(buffer & 0x3F)
|
||||
);
|
||||
}
|
||||
|
||||
if (padding == 2) {
|
||||
a = input.charCodeAt(position) << 8;
|
||||
b = input.charCodeAt(++position);
|
||||
buffer = a + b;
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 10) +
|
||||
__btoa_TABLE.charAt((buffer >> 4) & 0x3F) +
|
||||
__btoa_TABLE.charAt((buffer << 2) & 0x3F) +
|
||||
'='
|
||||
);
|
||||
} else if (padding == 1) {
|
||||
buffer = input.charCodeAt(position);
|
||||
output += (
|
||||
__btoa_TABLE.charAt(buffer >> 2) +
|
||||
__btoa_TABLE.charAt((buffer << 4) & 0x3F) +
|
||||
'=='
|
||||
);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
function atob(input) {
|
||||
input = String(input)
|
||||
.replace(__REGEX_SPACE_CHARACTERS, '');
|
||||
var length = input.length;
|
||||
if (length % 4 == 0) {
|
||||
input = input.replace(/==?$/, '');
|
||||
length = input.length;
|
||||
}
|
||||
if (
|
||||
length % 4 == 1 ||
|
||||
// http://whatwg.org/C#alphanumeric-ascii-characters
|
||||
/[^+a-zA-Z0-9/]/.test(input)
|
||||
) {
|
||||
error(
|
||||
'Invalid character: the string to be decoded is not correctly encoded.'
|
||||
);
|
||||
}
|
||||
var bitCounter = 0;
|
||||
var bitStorage;
|
||||
var buffer;
|
||||
var output = '';
|
||||
var position = -1;
|
||||
while (++position < length) {
|
||||
buffer = __btoa_TABLE.indexOf(input.charAt(position));
|
||||
bitStorage = bitCounter % 4 ? bitStorage * 64 + buffer : buffer;
|
||||
// Unless this is the first of a group of 4 characters…
|
||||
if (bitCounter++ % 4) {
|
||||
// …convert the first 8 bits to a single ASCII character.
|
||||
output += String.fromCharCode(
|
||||
0xFF & bitStorage >> (-2 * bitCounter & 6)
|
||||
);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
|
319
app/src/main/java/com/futo/platformplayer/CSSColor.kt
Normal file
319
app/src/main/java/com/futo/platformplayer/CSSColor.kt
Normal file
|
@ -0,0 +1,319 @@
|
|||
import kotlin.math.*
|
||||
|
||||
class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) {
|
||||
init {
|
||||
require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) {
|
||||
"RGBA channels must be in [0,1]"
|
||||
}
|
||||
}
|
||||
|
||||
// -- RGB(A) channels stored 0–1 --
|
||||
var r: Float = r.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var g: Float = g.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var b: Float = b.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||
var a: Float = a.coerceIn(0f, 1f)
|
||||
set(v) { field = v.coerceIn(0f, 1f) }
|
||||
|
||||
// -- Int views of RGBA 0–255 --
|
||||
var red: Int
|
||||
get() = (r * 255).roundToInt()
|
||||
set(v) { r = (v.coerceIn(0, 255) / 255f) }
|
||||
var green: Int
|
||||
get() = (g * 255).roundToInt()
|
||||
set(v) { g = (v.coerceIn(0, 255) / 255f) }
|
||||
var blue: Int
|
||||
get() = (b * 255).roundToInt()
|
||||
set(v) { b = (v.coerceIn(0, 255) / 255f) }
|
||||
var alpha: Int
|
||||
get() = (a * 255).roundToInt()
|
||||
set(v) { a = (v.coerceIn(0, 255) / 255f) }
|
||||
|
||||
// -- HSLA storage & lazy recompute flags --
|
||||
private var _h: Float = 0f
|
||||
private var _s: Float = 0f
|
||||
private var _l: Float = 0f
|
||||
private var _hslDirty = true
|
||||
|
||||
/** Hue [0...360) */
|
||||
var hue: Float
|
||||
get() { computeHslIfNeeded(); return _h }
|
||||
set(v) { setHsl(v, saturation, lightness) }
|
||||
|
||||
/** Saturation [0...1] */
|
||||
var saturation: Float
|
||||
get() { computeHslIfNeeded(); return _s }
|
||||
set(v) { setHsl(hue, v, lightness) }
|
||||
|
||||
/** Lightness [0...1] */
|
||||
var lightness: Float
|
||||
get() { computeHslIfNeeded(); return _l }
|
||||
set(v) { setHsl(hue, saturation, v) }
|
||||
|
||||
private fun computeHslIfNeeded() {
|
||||
if (!_hslDirty) return
|
||||
val max = max(max(r, g), b)
|
||||
val min = min(min(r, g), b)
|
||||
val d = max - min
|
||||
_l = (max + min) / 2f
|
||||
_s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f))
|
||||
_h = when {
|
||||
d == 0f -> 0f
|
||||
max == r -> ((g - b) / d % 6f) * 60f
|
||||
max == g -> (((b - r) / d) + 2f) * 60f
|
||||
else -> (((r - g) / d) + 4f) * 60f
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
_hslDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Set all three HSL channels at once.
|
||||
* Hue in degrees [0...360), s/l [0...1].
|
||||
*/
|
||||
fun setHsl(h: Float, s: Float, l: Float) {
|
||||
val hh = ((h % 360f) + 360f) % 360f
|
||||
val cc = (1f - abs(2f * l - 1f)) * s
|
||||
val x = cc * (1f - abs((hh / 60f) % 2f - 1f))
|
||||
val m = l - cc / 2f
|
||||
|
||||
val (rp, gp, bp) = when {
|
||||
hh < 60f -> Triple(cc, x, 0f)
|
||||
hh < 120f -> Triple(x, cc, 0f)
|
||||
hh < 180f -> Triple(0f, cc, x)
|
||||
hh < 240f -> Triple(0f, x, cc)
|
||||
hh < 300f -> Triple(x, 0f, cc)
|
||||
else -> Triple(cc, 0f, x)
|
||||
}
|
||||
|
||||
r = rp + m; g = gp + m; b = bp + m
|
||||
_h = hh; _s = s; _l = l; _hslDirty = false
|
||||
}
|
||||
|
||||
/** Return 0xRRGGBBAA int */
|
||||
fun toRgbaInt(): Int {
|
||||
val ai = (a * 255).roundToInt() and 0xFF
|
||||
val ri = (r * 255).roundToInt() and 0xFF
|
||||
val gi = (g * 255).roundToInt() and 0xFF
|
||||
val bi = (b * 255).roundToInt() and 0xFF
|
||||
return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai
|
||||
}
|
||||
|
||||
/** Return 0xAARRGGBB int */
|
||||
fun toArgbInt(): Int {
|
||||
val ai = (a * 255).roundToInt() and 0xFF
|
||||
val ri = (r * 255).roundToInt() and 0xFF
|
||||
val gi = (g * 255).roundToInt() and 0xFF
|
||||
val bi = (b * 255).roundToInt() and 0xFF
|
||||
return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi
|
||||
}
|
||||
|
||||
// — Convenience modifiers (chainable) —
|
||||
|
||||
/** Lighten by fraction [0...1] */
|
||||
fun lighten(fraction: Float): CSSColor = apply {
|
||||
lightness = (lightness + fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Darken by fraction [0...1] */
|
||||
fun darken(fraction: Float): CSSColor = apply {
|
||||
lightness = (lightness - fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Increase saturation by fraction [0...1] */
|
||||
fun saturate(fraction: Float): CSSColor = apply {
|
||||
saturation = (saturation + fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Decrease saturation by fraction [0...1] */
|
||||
fun desaturate(fraction: Float): CSSColor = apply {
|
||||
saturation = (saturation - fraction).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/** Rotate hue by degrees (can be negative) */
|
||||
fun rotateHue(degrees: Float): CSSColor = apply {
|
||||
hue = (hue + degrees) % 360f
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Create from Android 0xAARRGGBB */
|
||||
@JvmStatic fun fromArgb(color: Int): CSSColor {
|
||||
val a = ((color ushr 24) and 0xFF) / 255f
|
||||
val r = ((color ushr 16) and 0xFF) / 255f
|
||||
val g = ((color ushr 8) and 0xFF) / 255f
|
||||
val b = ( color and 0xFF) / 255f
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
/** Create from Android 0xRRGGBBAA */
|
||||
@JvmStatic fun fromRgba(color: Int): CSSColor {
|
||||
val r = ((color ushr 24) and 0xFF) / 255f
|
||||
val g = ((color ushr 16) and 0xFF) / 255f
|
||||
val b = ((color ushr 8) and 0xFF) / 255f
|
||||
val a = ( color and 0xFF) / 255f
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
@JvmStatic fun fromAndroidColor(color: Int): CSSColor {
|
||||
return fromArgb(color)
|
||||
}
|
||||
|
||||
private val NAMED_HEX = mapOf(
|
||||
"aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF",
|
||||
"aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC",
|
||||
"bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD",
|
||||
"blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A",
|
||||
"burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00",
|
||||
"chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED",
|
||||
"cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF",
|
||||
"darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B",
|
||||
"darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9",
|
||||
"darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F",
|
||||
"darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000",
|
||||
"darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B",
|
||||
"darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1",
|
||||
"darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF",
|
||||
"dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF",
|
||||
"firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22",
|
||||
"fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF",
|
||||
"gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080",
|
||||
"green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080",
|
||||
"honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C",
|
||||
"indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C",
|
||||
"lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00",
|
||||
"lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080",
|
||||
"lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3",
|
||||
"lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1",
|
||||
"lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA",
|
||||
"lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE",
|
||||
"lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32",
|
||||
"linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000",
|
||||
"mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3",
|
||||
"mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE",
|
||||
"mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585",
|
||||
"midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1",
|
||||
"moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080",
|
||||
"oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23",
|
||||
"orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6",
|
||||
"palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE",
|
||||
"palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9",
|
||||
"peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD",
|
||||
"powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399",
|
||||
"red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1",
|
||||
"saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460",
|
||||
"seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D",
|
||||
"silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD",
|
||||
"slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA",
|
||||
"springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C",
|
||||
"teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347",
|
||||
"turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3",
|
||||
"white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00",
|
||||
"yellowgreen" to "9ACD32"
|
||||
)
|
||||
private val NAMED: Map<String, Int> = NAMED_HEX
|
||||
.mapValues { (_, hexRgb) ->
|
||||
// parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity
|
||||
val rgb = hexRgb.toInt(16)
|
||||
(rgb shl 8) or 0xFF
|
||||
} + ("transparent" to 0x00000000)
|
||||
|
||||
private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE)
|
||||
private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||
private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||
|
||||
@JvmStatic
|
||||
fun parseColor(s: String): CSSColor {
|
||||
val str = s.trim()
|
||||
// named
|
||||
NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() }
|
||||
|
||||
// hex
|
||||
HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part ->
|
||||
return parseHexPart(part)
|
||||
}
|
||||
|
||||
// rgb/rgba
|
||||
RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||
return parseRgbParts(it.split(',').map(String::trim))
|
||||
}
|
||||
|
||||
// hsl/hsla
|
||||
HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||
return parseHslParts(it.split(',').map(String::trim))
|
||||
}
|
||||
|
||||
error("Cannot parse color: \"$s\"")
|
||||
}
|
||||
|
||||
private fun parseHexPart(p: String): CSSColor {
|
||||
// expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA"
|
||||
val hex = when (p.length) {
|
||||
3 -> p.map { "$it$it" }.joinToString("") + "FF"
|
||||
4 -> p.map { "$it$it" }.joinToString("")
|
||||
6 -> p + "FF"
|
||||
8 -> p
|
||||
else -> error("Invalid hex color: #$p")
|
||||
}
|
||||
|
||||
val parsed = hex.toLong(16).toInt()
|
||||
val alpha = (parsed and 0xFF) shl 24
|
||||
val rgbOnly = (parsed ushr 8) and 0x00FFFFFF
|
||||
val argb = alpha or rgbOnly
|
||||
return fromArgb(argb)
|
||||
}
|
||||
|
||||
private fun parseRgbParts(parts: List<String>): CSSColor {
|
||||
require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" }
|
||||
|
||||
// r/g/b: "128" → 128/255, "50%" → 0.5
|
||||
fun channel(ch: String): Float =
|
||||
if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f
|
||||
else ch.toFloat().coerceIn(0f, 255f) / 255f
|
||||
|
||||
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||
fun alpha(a: String): Float =
|
||||
if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f
|
||||
else a.toFloat().coerceIn(0f, 1f)
|
||||
|
||||
val r = channel(parts[0])
|
||||
val g = channel(parts[1])
|
||||
val b = channel(parts[2])
|
||||
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||
|
||||
return CSSColor(r, g, b, a)
|
||||
}
|
||||
|
||||
private fun parseHslParts(parts: List<String>): CSSColor {
|
||||
require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" }
|
||||
|
||||
fun hueOf(h: String): Float = when {
|
||||
h.endsWith("deg") -> h.removeSuffix("deg").toFloat()
|
||||
h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f
|
||||
h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat())
|
||||
h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f
|
||||
else -> h.toFloat()
|
||||
}
|
||||
|
||||
// for s and l you only ever see percentages
|
||||
fun pct(p: String): Float =
|
||||
p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f
|
||||
|
||||
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||
fun alpha(a: String): Float =
|
||||
if (a.endsWith("%")) pct(a)
|
||||
else a.toFloat().coerceIn(0f, 1f)
|
||||
|
||||
val h = hueOf(parts[0])
|
||||
val s = pct(parts[1])
|
||||
val l = pct(parts[2])
|
||||
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||
|
||||
return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this)
|
||||
fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this)
|
||||
fun CSSColor.toAndroidColor(): Int = toArgbInt()
|
|
@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
|||
@UnstableApi
|
||||
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||
val requestModifier = getRequestModifier();
|
||||
return if (requestModifier != null) {
|
||||
val requestExecutor = getRequestExecutor();
|
||||
return if (requestExecutor != null) {
|
||||
JSHttpDataSource.Factory().setRequestExecutor(requestExecutor);
|
||||
} else if (requestModifier != null) {
|
||||
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory();
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
@ -215,8 +216,13 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||
return InetAddress.getByAddress(this);
|
||||
}
|
||||
|
||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
||||
val timeout = 2000
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 10000
|
||||
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||
if(addresses.isEmpty())
|
||||
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||
|
||||
if (addresses.isEmpty()) {
|
||||
return null;
|
||||
|
@ -235,8 +241,11 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||
return null;
|
||||
}
|
||||
|
||||
val sortedAddresses: List<InetAddress> = addresses
|
||||
.sortedBy { addr -> addressScore(addr) }
|
||||
|
||||
val sockets: ArrayList<Socket> = arrayListOf();
|
||||
for (i in addresses.indices) {
|
||||
for (i in sortedAddresses.indices) {
|
||||
sockets.add(Socket());
|
||||
}
|
||||
|
||||
|
@ -244,7 +253,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||
var connectedSocket: Socket? = null;
|
||||
val threads: ArrayList<Thread> = arrayListOf();
|
||||
for (i in 0 until sockets.size) {
|
||||
val address = addresses[i];
|
||||
val address = sortedAddresses[i];
|
||||
val socket = sockets[i];
|
||||
val thread = Thread {
|
||||
try {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.base64UrlToByteArray
|
||||
import userpackage.Protocol
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
|
@ -40,33 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
|
|||
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
|
||||
}
|
||||
|
||||
fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? {
|
||||
val urlData = if (this.startsWith("polycentric://")) {
|
||||
this.substring("polycentric://".length)
|
||||
} else this;
|
||||
|
||||
val urlBytes = urlData.base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
|
||||
if (urlInfo.urlType != 4L) {
|
||||
return null
|
||||
}
|
||||
|
||||
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
|
||||
return dataLink
|
||||
}
|
||||
|
||||
fun Protocol.Claim.resolveChannelUrl(): String? {
|
||||
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||
}
|
||||
|
||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||
}
|
||||
|
||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
||||
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
|
||||
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
|
||||
addServer(PolycentricCache.SERVER)
|
||||
}
|
||||
|
||||
val exceptions = fullyBackfillServers()
|
||||
for (pair in exceptions) {
|
||||
val server = pair.key
|
||||
val exception = pair.value
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement(
|
||||
"backfill-failed",
|
||||
"Backfill failed",
|
||||
"Failed to backfill server $server. $exception",
|
||||
AnnouncementType.SESSION_RECURRING
|
||||
);
|
||||
|
||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,15 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLEncoder
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
//Syntax sugaring
|
||||
inline fun <reified T> Any.assume(): T?{
|
||||
|
@ -25,4 +31,42 @@ fun String?.yesNoToBoolean(): Boolean {
|
|||
|
||||
fun Boolean?.toYesNo(): String {
|
||||
return if (this == true) "YES" else "NO"
|
||||
}
|
||||
|
||||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> {
|
||||
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
val index = hostAddr.indexOf('%')
|
||||
if (index != -1) {
|
||||
val addrPart = hostAddr.substring(0, index)
|
||||
val scopeId = hostAddr.substring(index + 1)
|
||||
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||
} else {
|
||||
"[$hostAddr]"
|
||||
}
|
||||
}
|
||||
is Inet4Address -> {
|
||||
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||
}
|
||||
else -> {
|
||||
throw Exception("Invalid address type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||
if (this == null || this < 0)
|
||||
return OffsetDateTime.MIN
|
||||
if(this > 4070912400)
|
||||
return OffsetDateTime.MAX;
|
||||
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||
}
|
|
@ -2,10 +2,30 @@ package com.futo.platformplayer
|
|||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.*
|
||||
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueError
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.selects.SelectClause0
|
||||
import kotlinx.coroutines.selects.SelectClause1
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType
|
||||
|
||||
|
||||
//V8
|
||||
|
@ -24,6 +44,10 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
|
|||
return handler(this);
|
||||
}
|
||||
|
||||
inline fun V8Value.getSourcePlugin(): V8Plugin? {
|
||||
return V8Plugin.getPluginFromRuntime(this.v8Runtime);
|
||||
}
|
||||
|
||||
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
|
||||
if(this !is T)
|
||||
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
|
||||
|
@ -89,7 +113,29 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
|
|||
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
|
||||
}
|
||||
|
||||
inline fun V8Plugin.ensureIsBusy() {
|
||||
this.let {
|
||||
if (!it.isThreadAlreadyBusy()) {
|
||||
//throw IllegalStateException("Tried to access V8Plugin without busy");
|
||||
val stacktrace = Thread.currentThread().stackTrace;
|
||||
Logger.w("Extensions_V8",
|
||||
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
|
||||
", " + stacktrace.drop(4)?.firstOrNull().toString() +
|
||||
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
|
||||
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
inline fun V8Value.ensureIsBusy() {
|
||||
this?.getSourcePlugin()?.let {
|
||||
it.ensureIsBusy();
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
||||
if(false)
|
||||
ensureIsBusy();
|
||||
return when(T::class) {
|
||||
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
||||
Int::class -> {
|
||||
|
@ -146,4 +192,137 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
|||
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
||||
map.put(prop, obj.getString(prop));
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
val latch = CountDownLatch(1);
|
||||
var promiseResult: T? = null;
|
||||
var promiseException: Throwable? = null;
|
||||
plugin.busy {
|
||||
this.register(object: IV8ValuePromise.IListener {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
if(p0 is V8ValueError)
|
||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||
else
|
||||
promiseResult = p0 as T;
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onRejected promise not implemented.."));
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onCatch promise not implemented.."));
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
plugin.registerPromise(this) {
|
||||
promiseException = CancellationException("Cancelled by system");
|
||||
latch.countDown();
|
||||
}
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
}
|
||||
if(promiseException != null)
|
||||
throw promiseException!!;
|
||||
return promiseResult!!;
|
||||
}
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T> {
|
||||
val underlyingDef = CompletableDeferred<T>();
|
||||
val def = if(this.has("estDuration"))
|
||||
V8Deferred(underlyingDef,
|
||||
this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1);
|
||||
else
|
||||
V8Deferred<T>(underlyingDef);
|
||||
|
||||
if(def.estDuration > 0)
|
||||
Logger.i("V8", "Promise with duration: [${def.estDuration}]");
|
||||
|
||||
val promise = this;
|
||||
plugin.busy {
|
||||
this.register(object: IV8ValuePromise.IListener {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.complete(p0 as T);
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
||||
}
|
||||
});
|
||||
}
|
||||
plugin.registerPromise(promise) {
|
||||
if(def.isActive)
|
||||
def.cancel("Cancelled by system");
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||
|
||||
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||
val newDef = CompletableDeferred<R>()
|
||||
this.invokeOnCompletion {
|
||||
if(it != null)
|
||||
newDef.completeExceptionally(it);
|
||||
else
|
||||
newDef.complete(conversion(this@V8Deferred.getCompleted()));
|
||||
}
|
||||
|
||||
return V8Deferred<R>(newDef, estDuration);
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
fun <T, R> merge(scope: CoroutineScope, defs: List<V8Deferred<T>>, conversion: (result: List<T>)->R): V8Deferred<R> {
|
||||
|
||||
var amount = -1;
|
||||
for(def in defs)
|
||||
amount = Math.max(amount, def.estDuration);
|
||||
|
||||
val def = scope.async {
|
||||
val results = defs.map { it.await() };
|
||||
return@async conversion(results);
|
||||
}
|
||||
return V8Deferred(def, amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueAsync(this.getSourcePlugin()!!);
|
||||
}
|
||||
return V8Deferred(CompletableDeferred(result as T));
|
||||
}
|
||||
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
|
||||
var result = this.invoke<V8Value>(method, *obj);
|
||||
if(result is V8ValuePromise) {
|
||||
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
|
||||
return result;
|
||||
}
|
||||
return V8Deferred(CompletableDeferred(result));
|
||||
}
|
|
@ -0,0 +1,192 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.google.common.base.Preconditions
|
||||
import com.google.common.io.ByteStreams
|
||||
import com.google.common.primitives.Ints
|
||||
import com.google.common.primitives.Longs
|
||||
import java.io.DataInput
|
||||
import java.io.DataInputStream
|
||||
import java.io.EOFException
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
class LittleEndianDataInputStream
|
||||
/**
|
||||
* Creates a `LittleEndianDataInputStream` that wraps the given stream.
|
||||
*
|
||||
* @param in the stream to delegate to
|
||||
*/
|
||||
(`in`: InputStream?) : FilterInputStream(Preconditions.checkNotNull(`in`)), DataInput {
|
||||
/** This method will throw an [UnsupportedOperationException]. */
|
||||
override fun readLine(): String {
|
||||
throw UnsupportedOperationException("readLine is not supported")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readFully(b: ByteArray) {
|
||||
ByteStreams.readFully(this, b)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readFully(b: ByteArray, off: Int, len: Int) {
|
||||
ByteStreams.readFully(this, b, off, len)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun skipBytes(n: Int): Int {
|
||||
return `in`.skip(n.toLong()).toInt()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readUnsignedByte(): Int {
|
||||
val b1 = `in`.read()
|
||||
if (0 > b1) {
|
||||
throw EOFException()
|
||||
}
|
||||
|
||||
return b1
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an unsigned `short` as specified by [DataInputStream.readUnsignedShort],
|
||||
* except using little-endian byte order.
|
||||
*
|
||||
* @return the next two bytes of the input stream, interpreted as an unsigned 16-bit integer in
|
||||
* little-endian byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readUnsignedShort(): Int {
|
||||
val b1 = readAndCheckByte()
|
||||
val b2 = readAndCheckByte()
|
||||
|
||||
return Ints.fromBytes(0.toByte(), 0.toByte(), b2, b1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an integer as specified by [DataInputStream.readInt], except using little-endian
|
||||
* byte order.
|
||||
*
|
||||
* @return the next four bytes of the input stream, interpreted as an `int` in little-endian
|
||||
* byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readInt(): Int {
|
||||
val b1 = readAndCheckByte()
|
||||
val b2 = readAndCheckByte()
|
||||
val b3 = readAndCheckByte()
|
||||
val b4 = readAndCheckByte()
|
||||
|
||||
return Ints.fromBytes(b4, b3, b2, b1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `long` as specified by [DataInputStream.readLong], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @return the next eight bytes of the input stream, interpreted as a `long` in
|
||||
* little-endian byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readLong(): Long {
|
||||
val b1 = readAndCheckByte()
|
||||
val b2 = readAndCheckByte()
|
||||
val b3 = readAndCheckByte()
|
||||
val b4 = readAndCheckByte()
|
||||
val b5 = readAndCheckByte()
|
||||
val b6 = readAndCheckByte()
|
||||
val b7 = readAndCheckByte()
|
||||
val b8 = readAndCheckByte()
|
||||
|
||||
return Longs.fromBytes(b8, b7, b6, b5, b4, b3, b2, b1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `float` as specified by [DataInputStream.readFloat], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @return the next four bytes of the input stream, interpreted as a `float` in
|
||||
* little-endian byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readFloat(): Float {
|
||||
return java.lang.Float.intBitsToFloat(readInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `double` as specified by [DataInputStream.readDouble], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @return the next eight bytes of the input stream, interpreted as a `double` in
|
||||
* little-endian byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readDouble(): Double {
|
||||
return java.lang.Double.longBitsToDouble(readLong())
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readUTF(): String {
|
||||
return DataInputStream(`in`).readUTF()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `short` as specified by [DataInputStream.readShort], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @return the next two bytes of the input stream, interpreted as a `short` in little-endian
|
||||
* byte order.
|
||||
* @throws IOException if an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readShort(): Short {
|
||||
return readUnsignedShort().toShort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a char as specified by [DataInputStream.readChar], except using little-endian
|
||||
* byte order.
|
||||
*
|
||||
* @return the next two bytes of the input stream, interpreted as a `char` in little-endian
|
||||
* byte order
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun readChar(): Char {
|
||||
return readUnsignedShort().toChar()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readByte(): Byte {
|
||||
return readUnsignedByte().toByte()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun readBoolean(): Boolean {
|
||||
return readUnsignedByte() != 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a byte from the input stream checking that the end of file (EOF) has not been
|
||||
* encountered.
|
||||
*
|
||||
* @return byte read from input
|
||||
* @throws IOException if an error is encountered while reading
|
||||
* @throws EOFException if the end of file (EOF) is encountered.
|
||||
*/
|
||||
@Throws(IOException::class, EOFException::class)
|
||||
private fun readAndCheckByte(): Byte {
|
||||
val b1 = `in`.read()
|
||||
|
||||
if (-1 == b1) {
|
||||
throw EOFException()
|
||||
}
|
||||
|
||||
return b1.toByte()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import com.google.common.base.Preconditions
|
||||
import com.google.common.primitives.Longs
|
||||
import java.io.*
|
||||
|
||||
class LittleEndianDataOutputStream
|
||||
/**
|
||||
* Creates a `LittleEndianDataOutputStream` that wraps the given stream.
|
||||
*
|
||||
* @param out the stream to delegate to
|
||||
*/
|
||||
(out: OutputStream?) : FilterOutputStream(DataOutputStream(Preconditions.checkNotNull(out))),
|
||||
DataOutput {
|
||||
@Throws(IOException::class)
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
// Override slow FilterOutputStream impl
|
||||
out.write(b, off, len)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeBoolean(v: Boolean) {
|
||||
(out as DataOutputStream).writeBoolean(v)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeByte(v: Int) {
|
||||
(out as DataOutputStream).writeByte(v)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"""The semantics of {@code writeBytes(String s)} are considered dangerous. Please use
|
||||
{@link #writeUTF(String s)}, {@link #writeChars(String s)} or another write method instead."""
|
||||
)
|
||||
@Throws(
|
||||
IOException::class
|
||||
)
|
||||
override fun writeBytes(s: String) {
|
||||
(out as DataOutputStream).writeBytes(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a char as specified by [DataOutputStream.writeChar], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeChar(v: Int) {
|
||||
writeShort(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a `String` as specified by [DataOutputStream.writeChars], except
|
||||
* each character is written using little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeChars(s: String) {
|
||||
for (i in 0 until s.length) {
|
||||
writeChar(s[i].code)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a `double` as specified by [DataOutputStream.writeDouble], except
|
||||
* using little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeDouble(v: Double) {
|
||||
writeLong(java.lang.Double.doubleToLongBits(v))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a `float` as specified by [DataOutputStream.writeFloat], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeFloat(v: Float) {
|
||||
writeInt(java.lang.Float.floatToIntBits(v))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an `int` as specified by [DataOutputStream.writeInt], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeInt(v: Int) {
|
||||
val bytes = byteArrayOf(
|
||||
(0xFF and v).toByte(),
|
||||
(0xFF and (v shr 8)).toByte(),
|
||||
(0xFF and (v shr 16)).toByte(),
|
||||
(0xFF and (v shr 24)).toByte()
|
||||
)
|
||||
out.write(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a `long` as specified by [DataOutputStream.writeLong], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeLong(v: Long) {
|
||||
val bytes = Longs.toByteArray(java.lang.Long.reverseBytes(v))
|
||||
write(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a `short` as specified by [DataOutputStream.writeShort], except using
|
||||
* little-endian byte order.
|
||||
*
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun writeShort(v: Int) {
|
||||
val bytes = byteArrayOf(
|
||||
(0xFF and v).toByte(),
|
||||
(0xFF and (v shr 8)).toByte()
|
||||
)
|
||||
out.write(bytes)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun writeUTF(str: String) {
|
||||
(out as DataOutputStream).writeUTF(str)
|
||||
}
|
||||
|
||||
// Overriding close() because FilterOutputStream's close() method pre-JDK8 has bad behavior:
|
||||
// it silently ignores any exception thrown by flush(). Instead, just close the delegate stream.
|
||||
// It should flush itself if necessary.
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
out.close()
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import com.futo.platformplayer.activities.ManageTabsActivity
|
|||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SyncHomeActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
|
@ -28,11 +29,11 @@ import com.futo.platformplayer.states.StateUpdate
|
|||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.fields.AdvancedField
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -44,6 +45,7 @@ import kotlinx.serialization.json.Json
|
|||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
|
||||
@Serializable
|
||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||
|
||||
|
@ -57,7 +59,16 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Transient
|
||||
val onTabsChanged = Event0();
|
||||
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
|
||||
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
||||
@FormFieldButton(R.drawable.ic_update)
|
||||
fun syncGrayjay() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
|
@ -73,7 +84,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
|
||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -6)
|
||||
@FormFieldButton(R.drawable.ic_quiz)
|
||||
fun openFAQ() {
|
||||
try {
|
||||
|
@ -83,7 +94,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
//Ignored
|
||||
}
|
||||
}
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -4)
|
||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -5)
|
||||
@FormFieldButton(R.drawable.ic_data_alert)
|
||||
fun openIssues() {
|
||||
try {
|
||||
|
@ -115,7 +126,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}*/
|
||||
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
|
||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -4)
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
|
@ -129,16 +140,15 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
|
||||
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -2)
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||
@FormFieldButton(R.drawable.ic_move_up)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
val intent = MainActivity.getImportOptionsIntent(act);
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
|
||||
act.startActivity(intent);
|
||||
}
|
||||
|
||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
|
||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -2)
|
||||
@FormFieldButton(R.drawable.ic_link)
|
||||
fun manageLinks() {
|
||||
try {
|
||||
|
@ -148,6 +158,28 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
|
||||
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
||||
@FormFieldButton(R.drawable.battery_full_24px)
|
||||
fun ignoreBatteryOptimization() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
val intent = Intent()
|
||||
val packageName = it.packageName
|
||||
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||
intent.setAction(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
intent.setData(Uri.parse("package:$packageName"))
|
||||
it.startActivity(intent)
|
||||
UIDialogs.toast(it, "Please ignore battery optimizations for Grayjay")
|
||||
} else {
|
||||
UIDialogs.toast(it, "Battery optimizations already disabled for Grayjay")
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
@FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings")
|
||||
var advancedSettings: Boolean = false;
|
||||
|
||||
@FormField(R.string.language, "group", -1, 0)
|
||||
var language = LanguageSettings();
|
||||
@Serializable
|
||||
|
@ -178,7 +210,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var home = HomeSettings();
|
||||
@Serializable
|
||||
class HomeSettings {
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
|
||||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var homeFeedStyle: Int = 1;
|
||||
|
||||
|
@ -189,10 +221,16 @@ class Settings : FragmentedStorageFileJson() {
|
|||
return FeedStyle.THUMBNAIL;
|
||||
}
|
||||
|
||||
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||
var showHomeFilters: Boolean = true;
|
||||
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||
var showHomeFiltersPluginNames: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
|
@ -221,12 +259,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@DropdownFieldOptionsId(R.array.feed_style)
|
||||
var searchFeedStyle: Int = 1;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@FormField(R.string.hide_hidden_from_search, FieldForm.TOGGLE, R.string.hide_hidden_from_search_description, 7)
|
||||
var hidefromSearch: Boolean = false;
|
||||
|
||||
|
||||
fun getSearchFeedStyle(): FeedStyle {
|
||||
if(searchFeedStyle == 0)
|
||||
|
@ -242,6 +285,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable
|
||||
class ChannelSettings {
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||
var progressBar: Boolean = true;
|
||||
}
|
||||
|
@ -264,16 +308,23 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
||||
var showSubscriptionGroups: Boolean = true;
|
||||
|
||||
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
||||
var useSubscriptionExchange: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||
var previewFeedItems: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
|
||||
var progressBar: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var fetchOnAppBoot: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
|
||||
var fetchOnTabOpen: Boolean = true;
|
||||
|
||||
|
@ -304,13 +355,16 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
||||
var alwaysReloadFromCache: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
||||
var peekChannelContents: Boolean = false;
|
||||
|
||||
|
@ -326,7 +380,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var playback = PlaybackSettings();
|
||||
@Serializable
|
||||
class PlaybackSettings {
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
|
||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
|
||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||
var primaryLanguage: Int = 0;
|
||||
|
||||
|
@ -350,10 +404,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||
else -> null
|
||||
}
|
||||
}
|
||||
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
||||
var preferOriginalAudio: Boolean = true;
|
||||
|
||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
|
||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||
var defaultPlaybackSpeed: Int = 3;
|
||||
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
||||
|
@ -369,43 +425,38 @@ class Settings : FragmentedStorageFileJson() {
|
|||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
|
||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredQuality: Int = 0;
|
||||
|
||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
|
||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredMeteredQuality: Int = 0;
|
||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
||||
|
||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
|
||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
var preferredPreviewQuality: Int = 5;
|
||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||
|
||||
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
||||
var autoRotate: Int = 2;
|
||||
@AdvancedField
|
||||
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||
var simplifySources: Boolean = true;
|
||||
|
||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
|
||||
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
|
||||
|
||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6)
|
||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
||||
var autoRotateDeadZone: Int = 0;
|
||||
|
||||
fun getAutoRotateDeadZoneDegrees(): Int {
|
||||
return autoRotateDeadZone * 5;
|
||||
}
|
||||
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||
var backgroundPlay: Int = 2;
|
||||
|
||||
fun isBackgroundContinue() = backgroundPlay == 1;
|
||||
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
|
||||
@DropdownFieldOptionsId(R.array.resume_after_preview)
|
||||
var resumeAfterPreview: Int = 1;
|
||||
|
@ -432,14 +483,10 @@ class Settings : FragmentedStorageFileJson() {
|
|||
};
|
||||
}
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
||||
var useLiveChatWindow: Boolean = true;
|
||||
|
||||
|
||||
|
||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
||||
var backgroundSwitchToAudio: Boolean = true;
|
||||
|
||||
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
|
||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||
var restartPlaybackAfterLoss: Int = 1;
|
||||
|
@ -450,18 +497,134 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
||||
var fullscreenPortrait: Boolean = false;
|
||||
|
||||
@FormField(R.string.reverse_portrait, FieldForm.TOGGLE, R.string.reverse_portrait_description, 14)
|
||||
var reversePortrait: Boolean = false;
|
||||
|
||||
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 18)
|
||||
var preferWebmVideo: Boolean = false;
|
||||
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 19)
|
||||
var preferWebmAudio: Boolean = false;
|
||||
|
||||
@FormField(R.string.allow_under_cutout, FieldForm.TOGGLE, R.string.allow_under_cutout_description, 20)
|
||||
var allowVideoToGoUnderCutout: Boolean = true;
|
||||
|
||||
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
|
||||
var autoplay: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||
var deleteFromWatchLaterAuto: Boolean = true;
|
||||
|
||||
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
|
||||
@DropdownFieldOptionsId(R.array.seek_offset_duration)
|
||||
var seekOffset: Int = 2;
|
||||
|
||||
fun getSeekOffset(): Long {
|
||||
return when(seekOffset) {
|
||||
0 -> 3_000L;
|
||||
1 -> 5_000L;
|
||||
2 -> 10_000L;
|
||||
3 -> 20_000L;
|
||||
4 -> 30_000L;
|
||||
5 -> 60_000L;
|
||||
else -> 10_000L;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.min_playback_speed, FieldForm.DROPDOWN, R.string.min_playback_speed_description, 25)
|
||||
@DropdownFieldOptionsId(R.array.min_playback_speed)
|
||||
var minimumPlaybackSpeed: Int = 0;
|
||||
@FormField(R.string.max_playback_speed, FieldForm.DROPDOWN, R.string.max_playback_speed_description, 26)
|
||||
@DropdownFieldOptionsId(R.array.max_playback_speed)
|
||||
var maximumPlaybackSpeed: Int = 2;
|
||||
@FormField(R.string.step_playback_speed, FieldForm.DROPDOWN, R.string.step_playback_speed_description, 26)
|
||||
@DropdownFieldOptionsId(R.array.step_playback_speed)
|
||||
var stepPlaybackSpeed: Int = 1;
|
||||
|
||||
fun getPlaybackSpeedStep(): Double {
|
||||
return when(stepPlaybackSpeed) {
|
||||
0 -> 0.05
|
||||
1 -> 0.1
|
||||
2 -> 0.25
|
||||
else -> 0.1;
|
||||
}
|
||||
}
|
||||
fun getPlaybackSpeeds(): List<Double> {
|
||||
val playbackSpeeds = mutableListOf<Double>();
|
||||
playbackSpeeds.add(1.0);
|
||||
val minSpeed = when(minimumPlaybackSpeed) {
|
||||
0 -> 0.25
|
||||
1 -> 0.5
|
||||
2 -> 1.0
|
||||
else -> 0.25
|
||||
}
|
||||
val maxSpeed = when(maximumPlaybackSpeed) {
|
||||
0 -> 2.0
|
||||
1 -> 2.25
|
||||
2 -> 3.0
|
||||
3 -> 4.0
|
||||
4 -> 5.0
|
||||
else -> 2.25;
|
||||
}
|
||||
var testSpeed = 1.0;
|
||||
|
||||
while(testSpeed > minSpeed) {
|
||||
val nextSpeed = (testSpeed - 0.25) as Double;
|
||||
testSpeed = Math.max(nextSpeed, minSpeed);
|
||||
playbackSpeeds.add(testSpeed);
|
||||
}
|
||||
testSpeed = 1.0;
|
||||
while(testSpeed < maxSpeed) {
|
||||
val nextSpeed = (testSpeed + if(testSpeed < 2) 0.25 else 1.0) as Double;
|
||||
testSpeed = Math.min(nextSpeed, maxSpeed);
|
||||
playbackSpeeds.add(testSpeed);
|
||||
}
|
||||
playbackSpeeds.sort();
|
||||
return playbackSpeeds;
|
||||
}
|
||||
|
||||
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
|
||||
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
|
||||
var holdPlaybackSpeed: Int = 4;
|
||||
|
||||
fun getHoldPlaybackSpeed(): Double {
|
||||
return when(holdPlaybackSpeed) {
|
||||
0 -> 1.0
|
||||
1 -> 1.25
|
||||
2 -> 1.5
|
||||
3 -> 1.75
|
||||
4 -> 2.0
|
||||
5 -> 2.25
|
||||
6 -> 2.5
|
||||
7 -> 2.75
|
||||
8 -> 3.0
|
||||
else -> 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
var comments = CommentSettings();
|
||||
@Serializable
|
||||
class CommentSettings {
|
||||
var didAskPolycentricDefault: Boolean = false;
|
||||
|
||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||
var defaultCommentSection: Int = 0;
|
||||
var defaultCommentSection: Int = 2;
|
||||
|
||||
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
|
||||
var recommendationsDefault: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
|
||||
var hideRecommendations: Boolean = false;
|
||||
|
||||
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
||||
var badReputationCommentsFading: Boolean = true;
|
||||
|
||||
}
|
||||
|
||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||
|
@ -492,10 +655,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var preferredAudioQuality: Int = 1;
|
||||
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var byteRangeDownload: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
|
||||
@DropdownFieldOptionsId(R.array.thread_count)
|
||||
var byteRangeConcurrency: Int = 3;
|
||||
|
@ -510,7 +675,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
class Browsing {
|
||||
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var videoCache: Boolean = true;
|
||||
var videoCache: Boolean = false; //Temporary default disabled to prevent ui freeze?
|
||||
}
|
||||
|
||||
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||
|
@ -525,6 +690,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var keepScreenOn: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var alwaysProxyRequests: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowIpv6: Boolean = true;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.allow_ipv4, FieldForm.TOGGLE, R.string.allow_ipv4_description, 5)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowLinkLocalIpv4: Boolean = false;
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
|
@ -592,6 +772,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable
|
||||
class Plugins {
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||
var clearCookiesOnLogout: Boolean = true;
|
||||
|
||||
|
@ -775,10 +960,10 @@ class Settings : FragmentedStorageFileJson() {
|
|||
fun export() {
|
||||
val activity = SettingsActivity.getActivity() ?: return;
|
||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||
StateBackup.shareExternalBackup();
|
||||
}),
|
||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
|
||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
|
||||
StateBackup.saveExternalBackup(activity);
|
||||
})
|
||||
)
|
||||
|
@ -792,12 +977,32 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
|
||||
fun clearPayment() {
|
||||
StatePayment.instance.clearLicenses();
|
||||
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||
fun viewLicenseStatus() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||
it.reloadSettings();
|
||||
try {
|
||||
if (StatePayment.instance.hasPaid) {
|
||||
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first)
|
||||
} else {
|
||||
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show license status dialog", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||
fun clearPayment() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||
it.reloadSettings();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -806,12 +1011,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
||||
var bypassRotationPrevention: Boolean = false;
|
||||
@AdvancedField
|
||||
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
|
||||
var playlistDeleteConfirmation: Boolean = true;
|
||||
@AdvancedField
|
||||
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
|
||||
var playlistAllowDups: Boolean = true;
|
||||
|
||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
|
||||
@FormField(R.string.watch_later_add_start, FieldForm.TOGGLE, R.string.watch_later_add_start_description, 4)
|
||||
var watchLaterAddStart: Boolean = true;
|
||||
|
||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5)
|
||||
var polycentricEnabled: Boolean = true;
|
||||
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||
var polycentricLocalCache: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||
|
@ -843,7 +1057,39 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var pan: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
|
||||
@FormField(R.string.synchronization, FieldForm.GROUP, -1, 20)
|
||||
var synchronization = Synchronization();
|
||||
@Serializable
|
||||
class Synchronization {
|
||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||
var enabled: Boolean = false;
|
||||
|
||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||
var broadcast: Boolean = false;
|
||||
|
||||
@FormField(R.string.connect_discovered, FieldForm.TOGGLE, R.string.connect_discovered_description, 2)
|
||||
var connectDiscovered: Boolean = true;
|
||||
|
||||
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
||||
var connectLast: Boolean = true;
|
||||
|
||||
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
|
||||
var discoverThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
|
||||
var pairThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||
var connectThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||
var connectLocalDirectThroughRelay: Boolean = true;
|
||||
|
||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||
var localConnections: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||
var info = Info();
|
||||
@Serializable
|
||||
class Info {
|
||||
|
@ -909,4 +1155,4 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.CookieManager
|
||||
import androidx.work.Data
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
|
@ -8,6 +9,7 @@ import androidx.work.WorkManager
|
|||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.DeveloperActivity
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
@ -234,13 +236,17 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||
R.string.test_background_worker_description, 4)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
try {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||
.build();
|
||||
wm.enqueue(req);
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||
.build();
|
||||
wm.enqueue(req);
|
||||
} catch (e: Throwable) {
|
||||
UIDialogs.showGeneralErrorDialog(act, "Failed to trigger background update", e)
|
||||
}
|
||||
}
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
|
@ -491,6 +497,13 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField(R.string.test_playback, FieldForm.BUTTON,
|
||||
R.string.test_playback, 1)
|
||||
fun testPlayback(context: Context) {
|
||||
context.startActivity(MainActivity.getActionIntent(context, "TEST_PLAYBACK"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -5,7 +5,10 @@ import android.app.AlertDialog
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.text.Layout
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
|
@ -197,44 +200,51 @@ class UIDialogs {
|
|||
dialog.show();
|
||||
}
|
||||
|
||||
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||
}
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
builder.setView(view);
|
||||
|
||||
builder.setCancelable(defaultCloseAction > -2);
|
||||
val dialog = builder.create();
|
||||
registerDialogOpened(dialog);
|
||||
|
||||
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
||||
this.setImageResource(icon);
|
||||
if(animated)
|
||||
this.drawable.assume<Animatable, Unit> { it.start() };
|
||||
}
|
||||
view.findViewById<TextView>(R.id.dialog_text).apply {
|
||||
this.text = text;
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_details).apply {
|
||||
if(textDetails == null)
|
||||
this.visibility = View.GONE;
|
||||
else
|
||||
this.text = textDetails;
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if(code == null)
|
||||
if (textDetails == null)
|
||||
this.visibility = View.GONE;
|
||||
else {
|
||||
this.text = textDetails;
|
||||
}
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if (code == null) this.visibility = View.GONE;
|
||||
else {
|
||||
this.text = code;
|
||||
this.movementMethod = ScrollingMovementMethod.getInstance();
|
||||
this.visibility = View.VISIBLE;
|
||||
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||
}
|
||||
};
|
||||
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
||||
val center = actions.any { it?.center == true };
|
||||
val buttons = actions.map<Action, TextView> { act ->
|
||||
val buttonView = TextView(context);
|
||||
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
||||
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
||||
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
||||
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
if(actions.size > 1)
|
||||
this.marginEnd = if(actions.size > 2) dp14 else dp28;
|
||||
this.marginStart = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||
this.marginEnd = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||
};
|
||||
buttonView.setTextColor(Color.WHITE);
|
||||
buttonView.textSize = 14f;
|
||||
|
@ -256,7 +266,7 @@ class UIDialogs {
|
|||
|
||||
return@map buttonView;
|
||||
};
|
||||
if(actions.size <= 1)
|
||||
if(actions.size <= 1 || center)
|
||||
this.gravity = Gravity.CENTER;
|
||||
else
|
||||
this.gravity = Gravity.END;
|
||||
|
@ -271,6 +281,7 @@ class UIDialogs {
|
|||
registerDialogClosed(dialog);
|
||||
}
|
||||
dialog.show();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
||||
|
@ -308,7 +319,11 @@ class UIDialogs {
|
|||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
try {
|
||||
retryAction?.invoke();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
else
|
||||
|
@ -322,7 +337,11 @@ class UIDialogs {
|
|||
closeAction?.invoke()
|
||||
}, UIDialogs.ActionStyle.NONE),
|
||||
UIDialogs.Action(context.getString(R.string.retry), {
|
||||
retryAction?.invoke();
|
||||
try {
|
||||
retryAction?.invoke();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
|
@ -345,6 +364,13 @@ class UIDialogs {
|
|||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||
}
|
||||
|
||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||
val dialog = AutoUpdateDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
|
@ -357,8 +383,8 @@ class UIDialogs {
|
|||
}
|
||||
}
|
||||
|
||||
fun showChangelogDialog(context: Context, lastVersion: Int) {
|
||||
val dialog = ChangelogDialog(context);
|
||||
fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
|
||||
val dialog = ChangelogDialog(context, changelogs);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
|
@ -398,7 +424,7 @@ class UIDialogs {
|
|||
}
|
||||
|
||||
|
||||
fun showCastingDialog(context: Context) {
|
||||
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null) {
|
||||
val dialog = ConnectedCastingDialog(context);
|
||||
|
@ -406,6 +432,7 @@ class UIDialogs {
|
|||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
|
@ -418,21 +445,24 @@ class UIDialogs {
|
|||
if (c is Activity) {
|
||||
dialog.setOwnerActivity(c);
|
||||
}
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastingTutorialDialog(context: Context) {
|
||||
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingHelpDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showCastingAddDialog(context: Context) {
|
||||
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingAddDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
@ -507,11 +537,13 @@ class UIDialogs {
|
|||
val text: String;
|
||||
val action: ()->Unit;
|
||||
val style: ActionStyle;
|
||||
var center: Boolean;
|
||||
|
||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE) {
|
||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = action;
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
}
|
||||
enum class ActionStyle {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -13,6 +13,7 @@ import android.os.OperationCanceledException
|
|||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowInsetsController
|
||||
import android.view.WindowManager
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
|
@ -25,12 +26,24 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
import java.io.File
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InterfaceAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||
fun getRandomString(sizeOfRandomString: Int): String {
|
||||
|
@ -62,7 +75,14 @@ fun warnIfMainThread(context: String) {
|
|||
}
|
||||
|
||||
fun ensureNotMainThread() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
val isMainLooper = try {
|
||||
Looper.myLooper() == Looper.getMainLooper()
|
||||
} catch (e: Throwable) {
|
||||
//Ignore, for unit tests where its not mocked
|
||||
false
|
||||
}
|
||||
|
||||
if (isMainLooper) {
|
||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||
throw IllegalStateException("Cannot run on main thread")
|
||||
}
|
||||
|
@ -229,4 +249,213 @@ fun String.decodeUnicode(): String {
|
|||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> smartMerge(targetArr: List<T>, toMerge: List<T>) : List<T>{
|
||||
val missingToMerge = toMerge.filter { !targetArr.contains(it) }.toList();
|
||||
val newArrResult = targetArr.toMutableList();
|
||||
|
||||
for(missing in missingToMerge) {
|
||||
val newIndex = findNewIndex(toMerge, newArrResult, missing);
|
||||
newArrResult.add(newIndex, missing);
|
||||
}
|
||||
|
||||
return newArrResult;
|
||||
}
|
||||
fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
||||
var originalIndex = originalArr.indexOf(item);
|
||||
var newIndex = -1;
|
||||
|
||||
for(i in originalIndex-1 downTo 0) {
|
||||
val previousItem = originalArr[i];
|
||||
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||
if(indexInNewArr >= 0) {
|
||||
newIndex = indexInNewArr + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(newIndex < 0) {
|
||||
for(i in originalIndex+1 until originalArr.size) {
|
||||
val previousItem = originalArr[i];
|
||||
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||
if(indexInNewArr >= 0) {
|
||||
newIndex = indexInNewArr - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(newIndex < 0)
|
||||
return newArr.size;
|
||||
else
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
fun ByteBuffer.toUtf8String(): String {
|
||||
val remainingBytes = ByteArray(remaining())
|
||||
get(remainingBytes)
|
||||
return String(remainingBytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun generateReadablePassword(length: Int): String {
|
||||
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
||||
val secureRandom = SecureRandom()
|
||||
val randomBytes = ByteArray(length)
|
||||
secureRandom.nextBytes(randomBytes)
|
||||
val sb = StringBuilder(length)
|
||||
for (byte in randomBytes) {
|
||||
val index = (byte.toInt() and 0xFF) % validChars.length
|
||||
sb.append(validChars[index])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun ByteArray.toGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val gzipTimeStart = OffsetDateTime.now();
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
GZIPOutputStream(outputStream).use { gzip ->
|
||||
gzip.write(this)
|
||||
}
|
||||
val result = outputStream.toByteArray();
|
||||
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
|
||||
return result;
|
||||
}
|
||||
|
||||
fun ByteArray.fromGzip(): ByteArray {
|
||||
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||
|
||||
val inputStream = ByteArrayInputStream(this)
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
GZIPInputStream(inputStream).use { gzip ->
|
||||
val buffer = ByteArray(1024)
|
||||
var bytesRead: Int
|
||||
while (gzip.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
fun findCandidateAddresses(): List<InetAddress> {
|
||||
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||
.toList()
|
||||
.asSequence()
|
||||
.filter(::isUsableInterface)
|
||||
.flatMap { nif ->
|
||||
nif.interfaceAddresses
|
||||
.asSequence()
|
||||
.mapNotNull { ia ->
|
||||
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||
nif to ia
|
||||
}
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
return candidates
|
||||
.sortedWith(
|
||||
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||
{ addressScore(it.second.address) },
|
||||
{ interfaceScore(it.first) },
|
||||
{ -it.second.networkPrefixLength.toInt() },
|
||||
{ -it.first.mtu }
|
||||
)
|
||||
).map { it.second.address }
|
||||
}
|
||||
|
||||
fun findPreferredAddress(): InetAddress? {
|
||||
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||
.toList()
|
||||
.asSequence()
|
||||
.filter(::isUsableInterface)
|
||||
.flatMap { nif ->
|
||||
nif.interfaceAddresses
|
||||
.asSequence()
|
||||
.mapNotNull { ia ->
|
||||
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||
nif to ia
|
||||
}
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
return candidates
|
||||
.minWithOrNull(
|
||||
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||
{ addressScore(it.second.address) },
|
||||
{ interfaceScore(it.first) },
|
||||
{ -it.second.networkPrefixLength.toInt() },
|
||||
{ -it.first.mtu }
|
||||
)
|
||||
)?.second?.address
|
||||
}
|
||||
|
||||
private fun isUsableInterface(nif: NetworkInterface): Boolean {
|
||||
val name = nif.name.lowercase()
|
||||
return try {
|
||||
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
|
||||
nif.isUp
|
||||
&& !nif.isLoopback
|
||||
&& !nif.isPointToPoint
|
||||
&& !nif.isVirtual
|
||||
&& !name.startsWith("docker")
|
||||
&& !name.startsWith("veth")
|
||||
&& !name.startsWith("br-")
|
||||
&& !name.startsWith("virbr")
|
||||
&& !name.startsWith("vmnet")
|
||||
&& !name.startsWith("tun")
|
||||
&& !name.startsWith("tap")
|
||||
} catch (e: SocketException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUsableAddress(addr: InetAddress): Boolean {
|
||||
return when {
|
||||
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
|
||||
addr.isLoopbackAddress -> false
|
||||
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
|
||||
addr.isMulticastAddress -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun interfaceScore(nif: NetworkInterface): Int {
|
||||
val name = nif.name.lowercase()
|
||||
return when {
|
||||
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
|
||||
name.startsWith("eth") || name.contains("ethernet") -> 0
|
||||
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
|
||||
name.contains("wi-fi") || name.contains("wifi") -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
fun addressScore(addr: InetAddress): Int {
|
||||
return when (addr) {
|
||||
is Inet4Address -> {
|
||||
val octets = addr.address.map { it.toInt() and 0xFF }
|
||||
when {
|
||||
octets[0] == 10 -> 0 // 10/8
|
||||
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
|
||||
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12
|
||||
else -> 1 // public IPv4
|
||||
}
|
||||
}
|
||||
is Inet6Address -> {
|
||||
// ULA (fc00::/7) vs global vs others
|
||||
val b0 = addr.address[0].toInt() and 0xFF
|
||||
when {
|
||||
(b0 and 0xFE) == 0xFC -> 2 // ULA
|
||||
(b0 and 0xE0) == 0x20 -> 3 // global
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
|
@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
|
||||
class AddSourceOptionsActivity : AppCompatActivity() {
|
||||
lateinit var _buttonBack: ImageButton;
|
||||
|
||||
lateinit var _overlayContainer: FrameLayout;
|
||||
lateinit var _buttonQR: BigButton;
|
||||
lateinit var _buttonBrowse: BigButton;
|
||||
lateinit var _buttonURL: BigButton;
|
||||
|
@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||
setContentView(R.layout.activity_add_source_options);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
|
||||
_buttonQR = findViewById(R.id.option_qr);
|
||||
|
@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
_buttonURL.onClick.subscribe {
|
||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
||||
val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
|
||||
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
|
||||
|
||||
val content = nameInput.text;
|
||||
|
||||
val url = if (content.startsWith("https://")) {
|
||||
content
|
||||
} else if (content.startsWith("grayjay://plugin/")) {
|
||||
content.substring("grayjay://plugin/".length)
|
||||
} else {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||
return@showOverlay;
|
||||
}
|
||||
|
||||
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||
data = Uri.parse(url);
|
||||
};
|
||||
startActivity(intent);
|
||||
}, nameInput)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -113,7 +113,7 @@ class LoginActivity : AppCompatActivity() {
|
|||
|
||||
companion object {
|
||||
private val TAG = "LoginActivity";
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*");
|
||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||
|
||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,10 +14,12 @@ import android.widget.ImageButton
|
|||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.polycentric.core.ContentType
|
||||
|
@ -29,6 +31,9 @@ import com.futo.polycentric.core.toBase64Url
|
|||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
import userpackage.Protocol.URLInfo
|
||||
|
@ -39,6 +44,7 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _loader: View
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
|
@ -49,24 +55,47 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||
setContentView(R.layout.activity_polycentric_backup);
|
||||
setNavigationBarColorAndIcons();
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share);
|
||||
_buttonCopy = findViewById(R.id.button_copy);
|
||||
_imageQR = findViewById(R.id.image_qr);
|
||||
_textQR = findViewById(R.id.text_qr);
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
||||
_exportBundle = createExportBundle();
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
|
||||
try {
|
||||
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt();
|
||||
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
|
||||
_imageQR.setImageBitmap(qrCodeBitmap);
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
|
||||
_imageQR.visibility = View.INVISIBLE;
|
||||
_textQR.visibility = View.INVISIBLE;
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
val qr = generateQRCode(bundle, dimension, dimension)
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
_buttonShare.onClick.subscribe {
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.activities
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
|
@ -10,15 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ProcessHandle
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -27,6 +29,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _profileName: EditText;
|
||||
private lateinit var _buttonCreate: LinearLayout;
|
||||
private lateinit var _loader: LoaderView;
|
||||
private val TAG = "PolycentricCreateProfileActivity";
|
||||
|
||||
private var _creating = false;
|
||||
|
@ -43,6 +46,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_profileName = findViewById(R.id.edit_profile_name);
|
||||
_buttonCreate = findViewById(R.id.button_create_profile);
|
||||
_loader = findViewById(R.id.loader);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
@ -65,35 +69,49 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||
return@setOnClickListener;
|
||||
}
|
||||
|
||||
_profileName.isEnabled = false;
|
||||
_buttonCreate.visibility = View.GONE;
|
||||
_loader.start();
|
||||
_loader.visibility = View.VISIBLE;
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val processHandle: ProcessHandle;
|
||||
|
||||
try {
|
||||
processHandle = ProcessHandle.create();
|
||||
Store.instance.addProcessSecret(processHandle.processSecret);
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
||||
processHandle = ProcessHandle.create();
|
||||
Store.instance.addProcessSecret(processHandle.processSecret);
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
}
|
||||
|
||||
processHandle.addServer(ApiMethods.SERVER);
|
||||
processHandle.setUsername(username);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
|
||||
return@launch;
|
||||
} finally {
|
||||
_creating = false;
|
||||
}
|
||||
|
||||
processHandle.addServer(PolycentricCache.SERVER);
|
||||
processHandle.setUsername(username);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
|
||||
return@launch;
|
||||
} finally {
|
||||
_creating = false;
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||
finally {
|
||||
withContext(Dispatchers.Main) {
|
||||
_profileName.isEnabled = true;
|
||||
_buttonCreate.visibility = View.VISIBLE;
|
||||
_loader.stop();
|
||||
_loader.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.os.Bundle
|
|||
import android.util.TypedValue
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
|
@ -28,6 +29,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||
private lateinit var _buttonNewProfile: BigButton;
|
||||
private lateinit var _buttonImportProfile: BigButton;
|
||||
private lateinit var _layoutButtons: LinearLayout;
|
||||
private lateinit var _scroll: ScrollView;
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
|
@ -42,6 +44,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||
_buttonNewProfile = findViewById(R.id.button_new_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_layoutButtons = findViewById(R.id.layout_buttons);
|
||||
_scroll = findViewById(R.id.scroll);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
|
@ -78,6 +81,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||
|
||||
_layoutButtons.addView(profileButton, 0);
|
||||
}
|
||||
_scroll.invalidate();
|
||||
|
||||
_buttonHelp.setOnClickListener {
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||
|
|
|
@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
|
|||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.KeyPair
|
||||
import com.futo.polycentric.core.Process
|
||||
import com.futo.polycentric.core.ProcessSecret
|
||||
|
@ -145,7 +145,7 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
|
|
|
@ -21,18 +21,20 @@ import com.bumptech.glide.Glide
|
|||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.Store
|
||||
import com.futo.polycentric.core.SystemState
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
||||
import com.futo.polycentric.core.toBase64Url
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||
|
@ -47,6 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _editName: EditText;
|
||||
private lateinit var _buttonExport: BigButton;
|
||||
private lateinit var _buttonOpenHarborProfile: BigButton;
|
||||
private lateinit var _buttonLogout: BigButton;
|
||||
private lateinit var _buttonDelete: BigButton;
|
||||
private lateinit var _username: String;
|
||||
|
@ -68,10 +71,14 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||
_editName = findViewById(R.id.edit_profile_name);
|
||||
_buttonExport = findViewById(R.id.button_export);
|
||||
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
|
||||
_buttonLogout = findViewById(R.id.button_logout);
|
||||
_buttonDelete = findViewById(R.id.button_delete);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_textSystem = findViewById(R.id.text_system)
|
||||
findViewById<TextView>(R.id.text_cta2).setOnClickListener {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://harbor.social")))
|
||||
}
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
saveIfRequired();
|
||||
finish();
|
||||
|
@ -92,6 +99,16 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonOpenHarborProfile.onClick.subscribe {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
processHandle?.let {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
|
||||
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
|
||||
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
}
|
||||
}
|
||||
|
||||
_buttonLogout.onClick.subscribe {
|
||||
StatePolycentric.instance.setProcessHandle(null);
|
||||
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
||||
|
@ -108,6 +125,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
|
||||
StatePolycentric.instance.setProcessHandle(null);
|
||||
Store.instance.removeProcessSecret(processHandle.system);
|
||||
PolycentricStorage.instance.removeProcessSecret(processHandle.system);
|
||||
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
||||
finish();
|
||||
});
|
||||
|
@ -127,7 +145,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateUI();
|
||||
|
|
|
@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat
|
|||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.LoaderView
|
||||
|
@ -184,12 +185,19 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
settingsActivityClosed.emit()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private var _lastActivity: SettingsActivity? = null;
|
||||
|
||||
val settingsActivityClosed = Event0()
|
||||
|
||||
fun getActivity(): SettingsActivity? {
|
||||
val act = _lastActivity;
|
||||
if(act != null && !act._isFinished)
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.sync.internal.LinkType
|
||||
import com.futo.platformplayer.sync.internal.SyncSession
|
||||
import com.futo.platformplayer.views.sync.SyncDeviceView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SyncHomeActivity : AppCompatActivity() {
|
||||
private lateinit var _layoutDevices: LinearLayout
|
||||
private lateinit var _layoutEmpty: LinearLayout
|
||||
private val _viewMap: MutableMap<String, SyncDeviceView> = mutableMapOf()
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (StateApp.instance.contextOrNull == null) {
|
||||
Logger.w(TAG, "No main activity, restarting main.")
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContentView(R.layout.activity_sync_home)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_layoutDevices = findViewById(R.id.layout_devices)
|
||||
_layoutEmpty = findViewById(R.id.layout_empty)
|
||||
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
|
||||
startActivity(Intent(this@SyncHomeActivity, SyncPairActivity::class.java))
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.button_show_pairing_code).setOnClickListener {
|
||||
startActivity(Intent(this@SyncHomeActivity, SyncShowPairingCodeActivity::class.java))
|
||||
}
|
||||
|
||||
initializeDevices()
|
||||
|
||||
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { publicKey, session ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val view = _viewMap[publicKey]
|
||||
if (!session.isAuthorized) {
|
||||
if (view != null) {
|
||||
_viewMap.remove(publicKey)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (view == null) {
|
||||
val syncDeviceView = SyncDeviceView(this@SyncHomeActivity)
|
||||
syncDeviceView.onRemove.subscribe {
|
||||
StateApp.instance.scopeOrNull?.launch {
|
||||
StateSync.instance.delete(publicKey)
|
||||
}
|
||||
}
|
||||
val v = updateDeviceView(syncDeviceView, publicKey, session)
|
||||
_layoutDevices.addView(v, 0)
|
||||
_viewMap[publicKey] = v
|
||||
} else {
|
||||
updateDeviceView(view, publicKey, session)
|
||||
}
|
||||
|
||||
updateEmptyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
StateSync.instance.deviceRemoved.subscribe(this) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val view = _viewMap[it]
|
||||
if (view != null) {
|
||||
_layoutDevices.removeView(view)
|
||||
_viewMap.remove(it)
|
||||
}
|
||||
|
||||
updateEmptyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
StateSync.instance.confirmStarted(this, onStarted = {
|
||||
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
|
||||
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
|
||||
}
|
||||
if (StateSync.instance.syncService?.relayConnected == false) {
|
||||
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
|
||||
}
|
||||
if (StateSync.instance.syncService?.serverSocketStarted == false) {
|
||||
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
|
||||
}
|
||||
}, onNotStarted = {
|
||||
finish()
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
StateSync.instance.deviceUpdatedOrAdded.remove(this)
|
||||
StateSync.instance.deviceRemoved.remove(this)
|
||||
}
|
||||
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
val authorized = session?.isAuthorized ?: false
|
||||
|
||||
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||
//TODO: also display public key?
|
||||
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
private fun updateEmptyVisibility() {
|
||||
if (_viewMap.isNotEmpty()) {
|
||||
_layoutEmpty.visibility = View.GONE
|
||||
} else {
|
||||
_layoutEmpty.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeDevices() {
|
||||
_layoutDevices.removeAllViews()
|
||||
|
||||
for (publicKey in StateSync.instance.getAll()) {
|
||||
val syncDeviceView = SyncDeviceView(this)
|
||||
syncDeviceView.onRemove.subscribe {
|
||||
StateApp.instance.scopeOrNull?.launch {
|
||||
StateSync.instance.delete(publicKey)
|
||||
}
|
||||
}
|
||||
val view = updateDeviceView(syncDeviceView, publicKey, StateSync.instance.getSession(publicKey))
|
||||
_layoutDevices.addView(view)
|
||||
_viewMap[publicKey] = view
|
||||
}
|
||||
|
||||
updateEmptyVisibility()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncHomeActivity"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class SyncPairActivity : AppCompatActivity() {
|
||||
private lateinit var _editCode: EditText
|
||||
|
||||
private lateinit var _layoutPairing: LinearLayout
|
||||
private lateinit var _textPairingStatus: TextView
|
||||
|
||||
private lateinit var _layoutPairingSuccess: LinearLayout
|
||||
|
||||
private lateinit var _layoutPairingError: LinearLayout
|
||||
private lateinit var _textError: TextView
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
_editCode.text.clear()
|
||||
_editCode.text.append(it.contents)
|
||||
pair(it.contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_sync_pair)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_editCode = findViewById(R.id.edit_code)
|
||||
_layoutPairing = findViewById(R.id.layout_pairing)
|
||||
_textPairingStatus = findViewById(R.id.text_pairing_status)
|
||||
_layoutPairingSuccess = findViewById(R.id.layout_pairing_success)
|
||||
_layoutPairingError = findViewById(R.id.layout_pairing_error)
|
||||
_textError = findViewById(R.id.text_error)
|
||||
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.button_scan_qr).setOnClickListener {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
|
||||
pair(_editCode.text.toString())
|
||||
}
|
||||
|
||||
_layoutPairingSuccess.setOnClickListener {
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
finish()
|
||||
}
|
||||
_layoutPairingError.setOnClickListener {
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
}
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
}
|
||||
|
||||
fun pair(url: String) {
|
||||
try {
|
||||
_layoutPairing.visibility = View.VISIBLE
|
||||
_textPairingStatus.text = "Parsing text..."
|
||||
|
||||
if (!url.startsWith("grayjay://sync/")) {
|
||||
throw Exception("Not a valid URL: $url")
|
||||
}
|
||||
|
||||
val deviceInfo: SyncDeviceInfo = Json.decodeFromString<SyncDeviceInfo>(Base64.decode(url.substring("grayjay://sync/".length), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).decodeToString())
|
||||
if (StateSync.instance.isAuthorized(deviceInfo.publicKey)) {
|
||||
throw Exception("This device is already paired")
|
||||
}
|
||||
|
||||
_textPairingStatus.text = "Connecting..."
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
} else {
|
||||
_textError.text = message
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
} else {
|
||||
_textPairingStatus.text = message
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
if(e.message == "Failed to connect") {
|
||||
_textError.text = "Failed to connect.\n\nThis may be due to not being on the same network, due to firewall, or vpn.\nSync currently operates only over local direct connections."
|
||||
}
|
||||
else
|
||||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e: Throwable) {
|
||||
_layoutPairingError.visibility = View.VISIBLE
|
||||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncPairActivity"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.MultiFormatWriter
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class SyncShowPairingCodeActivity : AppCompatActivity() {
|
||||
private lateinit var _textCode: TextView
|
||||
private lateinit var _imageQR: ImageView
|
||||
private lateinit var _textQR: TextView
|
||||
private var _code: String? = null
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
activity = this
|
||||
|
||||
setContentView(R.layout.activity_sync_show_pairing_code)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_textCode = findViewById(R.id.text_code)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_scan_qr)
|
||||
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
findViewById<LinearLayout>(R.id.button_copy).setOnClickListener {
|
||||
val code = _code ?: return@setOnClickListener
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), code);
|
||||
clipboard.setPrimaryClip(clip);
|
||||
UIDialogs.toast(this, "Copied to clipboard")
|
||||
}
|
||||
|
||||
val ips = getIPs()
|
||||
val publicKey = StateSync.instance.syncService?.publicKey
|
||||
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||
if (publicKey == null || pairingCode == null) {
|
||||
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||
} else {
|
||||
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
|
||||
val json = Json.encodeToString(selfDeviceInfo)
|
||||
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
val url = "grayjay://sync/${base64}"
|
||||
setCode(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setCode(code: String?) {
|
||||
_code = code
|
||||
|
||||
_textCode.text = code
|
||||
|
||||
if (code == null) {
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt()
|
||||
val qrCodeBitmap = generateQRCode(code, dimension, dimension)
|
||||
_imageQR.setImageBitmap(qrCodeBitmap)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||
return bitMatrixToBitmap(bitMatrix);
|
||||
}
|
||||
|
||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||
val width = matrix.width;
|
||||
val height = matrix.height;
|
||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE);
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private fun getIPs(): List<String> {
|
||||
val ips = arrayListOf<String>()
|
||||
for (intf in NetworkInterface.getNetworkInterfaces()) {
|
||||
for (addr in intf.inetAddresses) {
|
||||
if (addr.isLoopbackAddress) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (addr.address.size != 4) {
|
||||
continue
|
||||
}
|
||||
|
||||
addr.hostAddress?.let { ips.add(it) }
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncShowPairingCodeActivity"
|
||||
var activity: SyncShowPairingCodeActivity? = null
|
||||
private set
|
||||
}
|
||||
}
|
|
@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
|
|||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TestActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_test);
|
||||
|
||||
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
|
||||
view.startLoader(10000)
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(5000)
|
||||
view.startLoader()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.ensureNotMainThread
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
|
@ -17,13 +19,14 @@ import okhttp3.WebSocket
|
|||
import okhttp3.WebSocketListener
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.time.Duration
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
open class ManagedHttpClient {
|
||||
protected val _builderTemplate: OkHttpClient.Builder;
|
||||
protected var _builderTemplate: OkHttpClient.Builder;
|
||||
|
||||
private var client: OkHttpClient;
|
||||
|
||||
|
@ -32,6 +35,15 @@ open class ManagedHttpClient {
|
|||
|
||||
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||
|
||||
fun setTimeout(timeout: Long) {
|
||||
rebuildClient {
|
||||
it.callTimeout(Duration.ofMillis(client.callTimeoutMillis.toLong()))
|
||||
.writeTimeout(Duration.ofMillis(client.writeTimeoutMillis.toLong()))
|
||||
.readTimeout(Duration.ofMillis(client.readTimeoutMillis.toLong()))
|
||||
.connectTimeout(Duration.ofMillis(timeout));
|
||||
}
|
||||
}
|
||||
|
||||
private val trustAllCerts = arrayOf<TrustManager>(
|
||||
object: X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||
|
@ -53,7 +65,7 @@ open class ManagedHttpClient {
|
|||
|
||||
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
||||
_builderTemplate = builder;
|
||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||
if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||
trustAllCertificates(builder);
|
||||
client = builder.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
|
@ -62,6 +74,15 @@ open class ManagedHttpClient {
|
|||
}.build();
|
||||
}
|
||||
|
||||
fun rebuildClient(modify: (OkHttpClient.Builder) -> OkHttpClient.Builder) {
|
||||
_builderTemplate = modify(_builderTemplate);
|
||||
client = _builderTemplate.addNetworkInterceptor { chain ->
|
||||
val request = beforeRequest(chain.request());
|
||||
val response = afterRequest(chain.proceed(request));
|
||||
return@addNetworkInterceptor response;
|
||||
}.build();
|
||||
}
|
||||
|
||||
open fun clone(): ManagedHttpClient {
|
||||
val clonedClient = ManagedHttpClient(_builderTemplate);
|
||||
clonedClient.user_agent = user_agent;
|
||||
|
@ -69,6 +90,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
if(result.isOk)
|
||||
|
@ -83,7 +105,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||
|
||||
ensureNotMainThread()
|
||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||
.url(url);
|
||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||
|
@ -279,6 +301,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun send(msg: String) {
|
||||
ensureNotMainThread()
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
|
|
|
@ -210,6 +210,20 @@ class HttpContext : AutoCloseable {
|
|||
}
|
||||
}
|
||||
}
|
||||
fun respondBytes(status: Int, headers: HttpHeaders, body: ByteArray? = null) {
|
||||
if(headers.get("content-length").isNullOrEmpty()) {
|
||||
if (body != null) {
|
||||
headers.put("content-length", body.size.toString());
|
||||
} else {
|
||||
headers.put("content-length", "0")
|
||||
}
|
||||
}
|
||||
respond(status, headers) { responseStream ->
|
||||
if(body != null) {
|
||||
responseStream.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) {
|
||||
val responseStream = _responseStream ?: throw IllegalStateException("No response stream set");
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server
|
|||
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
@ -208,20 +208,20 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||
|
||||
for(getMethod in getMethods)
|
||||
if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1)
|
||||
addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||
addHandler(HttpFunctionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||
if(!getMethod.second.contentType.isEmpty())
|
||||
this.withContentType(getMethod.second.contentType);
|
||||
}.withContentType(getMethod.second.contentType);
|
||||
for(postMethod in postMethods)
|
||||
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
||||
addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||
addHandler(HttpFunctionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||
if(!postMethod.second.contentType.isEmpty())
|
||||
this.withContentType(postMethod.second.contentType);
|
||||
}.withContentType(postMethod.second.contentType);
|
||||
|
||||
for(getField in getFields) {
|
||||
getField.first.isAccessible = true;
|
||||
addHandler(HttpFuntionHandler("GET", getField.second.path) {
|
||||
addHandler(HttpFunctionHandler("GET", getField.second.path) {
|
||||
val value = getField.first.get(obj) as String?;
|
||||
if(value != null) {
|
||||
val headers = HttpHeaders(
|
||||
|
|
|
@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
|||
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||
|
||||
current += bytesToSend.toLong()
|
||||
if (current >= end) {
|
||||
if (current > end) {
|
||||
Logger.i(TAG, "Expected amount of bytes sent")
|
||||
break
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server.handlers
|
|||
|
||||
import com.futo.platformplayer.api.http.server.HttpContext
|
||||
|
||||
class HttpFuntionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
||||
class HttpFunctionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
||||
override fun handle(httpContext: HttpContext) {
|
||||
httpContext.setResponseHeaders(this.headers);
|
||||
handler(httpContext);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.futo.platformplayer.api.media
|
||||
|
||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -12,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
|
||||
|
@ -35,6 +37,11 @@ interface IPlatformClient {
|
|||
*/
|
||||
fun getHome(): IPager<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets the shorts feed
|
||||
*/
|
||||
fun getShorts(): IPager<IPlatformVideo>
|
||||
|
||||
//Search
|
||||
/**
|
||||
* Gets search suggestion for the provided query string
|
||||
|
@ -66,6 +73,11 @@ interface IPlatformClient {
|
|||
*/
|
||||
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
||||
|
||||
/**
|
||||
* Searches for channels and returns a content pager
|
||||
*/
|
||||
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
//Video Pages
|
||||
/**
|
||||
|
@ -170,6 +182,10 @@ interface IPlatformClient {
|
|||
* Retrieves the subscriptions of the currently logged in user
|
||||
*/
|
||||
fun getUserSubscriptions(): Array<String>;
|
||||
/**
|
||||
* Retrieves the history of the currently logged in user
|
||||
*/
|
||||
fun getUserHistory(): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||
|
|
|
@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
@ -26,12 +27,17 @@ class LiveChatManager {
|
|||
private val _emojiCache: EmojiCache = EmojiCache();
|
||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||
|
||||
private var _position: Long = 0;
|
||||
private var _eventsPosition: Long = 0;
|
||||
|
||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||
|
||||
private var _startCounter = 0;
|
||||
|
||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||
|
||||
val isVOD get() = _pager is JSVODEventPager;
|
||||
|
||||
var viewCount: Long = 0
|
||||
private set;
|
||||
|
||||
|
@ -39,8 +45,24 @@ class LiveChatManager {
|
|||
_scope = scope;
|
||||
_pager = pager;
|
||||
viewCount = initialViewCount;
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
handleEvents(pager.getResults());
|
||||
if(pager is JSVODEventPager)
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
else
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
|
||||
if(pager is JSVODEventPager) {
|
||||
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||
//TODO: Remove this once dripfeed is done properly
|
||||
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = _eventsPosition + 1500;
|
||||
}
|
||||
else
|
||||
handleEvents(pager.getResults());
|
||||
}
|
||||
|
||||
fun start() {
|
||||
|
@ -52,6 +74,10 @@ class LiveChatManager {
|
|||
_startCounter++;
|
||||
}
|
||||
|
||||
fun setVideoPosition(ms: Long) {
|
||||
_position = ms;
|
||||
}
|
||||
|
||||
fun getHistory(): List<IPlatformLiveEvent> {
|
||||
synchronized(_history) {
|
||||
return _history.toList();
|
||||
|
@ -85,13 +111,34 @@ class LiveChatManager {
|
|||
try {
|
||||
while(_startCounter == counter) {
|
||||
var nextInterval = 1000L;
|
||||
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||
delay(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if(_pager == null || !_pager.hasMorePages())
|
||||
return@launch;
|
||||
_pager.nextPage();
|
||||
val newEvents = _pager.getResults();
|
||||
val newEvents = if(_pager is JSVODEventPager) {
|
||||
val requestPosition = _position;
|
||||
_pager.nextPage(requestPosition.toInt());
|
||||
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
replayResults;
|
||||
}
|
||||
else {
|
||||
_pager.nextPage();
|
||||
_pager.getResults();
|
||||
}
|
||||
if(_pager is JSLiveEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
else if(_pager is JSVODEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
|
||||
if(newEvents.size > 0)
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
|
|
|
@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
|
|||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false,
|
||||
val hasGetChannelPlaylists: Boolean = false,
|
||||
val hasGetContentRecommendations: Boolean = false
|
||||
val hasGetContentRecommendations: Boolean = false,
|
||||
val hasGetUserHistory: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
|
@ -13,13 +13,17 @@ class PlatformClientPool {
|
|||
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
||||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
private val _privatePool: Boolean;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient, name: String? = null) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_poolName = name;
|
||||
_privatePool = privatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
|
@ -30,8 +34,10 @@ class PlatformClientPool {
|
|||
isDead = true;
|
||||
onDead.emit(parentClient, this);
|
||||
|
||||
for(clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
synchronized(_pool) {
|
||||
for (clientPair in _pool) {
|
||||
clientPair.key.disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -51,7 +57,7 @@ class PlatformClientPool {
|
|||
reserved = _pool.keys.find { !it.isBusy };
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy();
|
||||
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
|
||||
|
||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||
StateApp.instance.handleCaptchaException(client, ex);
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
|
@ -44,6 +45,7 @@ class PlatformID {
|
|||
val NONE = PlatformID("Unknown", null);
|
||||
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
|
||||
value.ensureIsBusy();
|
||||
val contextName = "PlatformID";
|
||||
return PlatformID(
|
||||
value.getOrThrow(config, "platform", contextName),
|
||||
|
|
|
@ -6,12 +6,16 @@ class PlatformMultiClientPool {
|
|||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
|
||||
private var _isFake = false;
|
||||
private var _privatePool = false;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
constructor(name: String, maxCap: Int = -1) {
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
_privatePool = isPrivatePool;
|
||||
_isolatedInitialization = isolatedInitialization
|
||||
}
|
||||
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||
|
@ -19,7 +23,7 @@ class PlatformMultiClientPool {
|
|||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
|
|
@ -4,6 +4,6 @@ import kotlinx.serialization.json.Json
|
|||
|
||||
class Serializer {
|
||||
companion object {
|
||||
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; };
|
||||
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true };
|
||||
}
|
||||
}
|
|
@ -2,7 +2,11 @@ package com.futo.platformplayer.api.media.models
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -27,7 +31,10 @@ open class PlatformAuthorLink {
|
|||
}
|
||||
|
||||
companion object {
|
||||
val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null);
|
||||
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||
value.ensureIsBusy();
|
||||
if(value.has("membershipUrl"))
|
||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||
|
||||
|
@ -40,4 +47,21 @@ open class PlatformAuthorLink {
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IPlatformChannelContent : IPlatformContent {
|
||||
val thumbnail: String?
|
||||
val subscribers: Long?
|
||||
}
|
||||
|
||||
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||
override val thumbnail: String?
|
||||
override val subscribers: Long?
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Channel";
|
||||
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models
|
|||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -20,6 +21,7 @@ class PlatformAuthorMembershipLink: PlatformAuthorLink {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
|
||||
value.ensureIsBusy();
|
||||
val context = "AuthorMembershipLink"
|
||||
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||
value.getOrThrow(config ,"name", context),
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueInteger
|
|||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.expectV8Variant
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
@ -30,6 +31,7 @@ class ResultCapabilities(
|
|||
const val TYPE_POSTS = "POSTS";
|
||||
const val TYPE_MIXED = "MIXED";
|
||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||
const val TYPE_SHORTS = "SHORTS";
|
||||
|
||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||
|
||||
|
@ -45,6 +47,7 @@ class ResultCapabilities(
|
|||
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities {
|
||||
val contextName = "ResultCapabilities";
|
||||
value.ensureIsBusy();
|
||||
return ResultCapabilities(
|
||||
value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
|
||||
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
|
||||
|
@ -68,6 +71,7 @@ class FilterGroup(
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup {
|
||||
value.ensureIsBusy();
|
||||
return FilterGroup(
|
||||
value.getString("name"),
|
||||
value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null)
|
||||
|
@ -89,6 +93,7 @@ class FilterCapability(
|
|||
|
||||
companion object {
|
||||
fun fromV8(obj: V8ValueObject): FilterCapability {
|
||||
obj.ensureIsBusy();
|
||||
val value = obj.get("value") as V8Value;
|
||||
return FilterCapability(
|
||||
obj.getString("name"),
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
|||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -31,6 +32,7 @@ class Thumbnails {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
||||
value.ensureIsBusy();
|
||||
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
||||
.toArray()
|
||||
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package com.futo.platformplayer.api.media.models.article
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
||||
interface IPlatformArticle: IPlatformContent {
|
||||
val summary: String?;
|
||||
val thumbnails: Thumbnails?;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.futo.platformplayer.api.media.models.article
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
|
||||
|
||||
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
|
||||
val segments: List<IJSArticleSegment>;
|
||||
val rating : IRating;
|
||||
}
|
|
@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
|
|||
companion object {
|
||||
fun fromInt(value: Int): ChapterType
|
||||
{
|
||||
val result = ChapterType.values().firstOrNull { it.value == value };
|
||||
val result = ChapterType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.futo.platformplayer.api.media.models.comments
|
||||
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingType
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.Deferred
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class LazyComment: IPlatformComment {
|
||||
private var _commentDeferred: Deferred<IPlatformComment>;
|
||||
private var _commentLoaded: IPlatformComment? = null;
|
||||
private var _commentException: Throwable? = null;
|
||||
|
||||
override val contextUrl: String
|
||||
get() = _commentLoaded?.contextUrl ?: "";
|
||||
override val author: PlatformAuthorLink
|
||||
get() = _commentLoaded?.author ?: PlatformAuthorLink.UNKNOWN;
|
||||
override val message: String
|
||||
get() = _commentLoaded?.message ?: "";
|
||||
override val rating: IRating
|
||||
get() = _commentLoaded?.rating ?: RatingLikes(0);
|
||||
override val date: OffsetDateTime?
|
||||
get() = _commentLoaded?.date ?: OffsetDateTime.MIN;
|
||||
override val replyCount: Int?
|
||||
get() = _commentLoaded?.replyCount ?: 0;
|
||||
|
||||
val isAvailable: Boolean get() = _commentLoaded != null;
|
||||
|
||||
private var _uiHandler: ((LazyComment)->Unit)? = null;
|
||||
|
||||
constructor(commentDeferred: Deferred<IPlatformComment>) {
|
||||
_commentDeferred = commentDeferred;
|
||||
_commentDeferred.invokeOnCompletion {
|
||||
if(it == null) {
|
||||
_commentLoaded = commentDeferred.getCompleted();
|
||||
Logger.i("LazyComment", "Resolved comment");
|
||||
}
|
||||
else {
|
||||
_commentException = it;
|
||||
Logger.e("LazyComment", "Resolving comment failed: ${it.message}", it);
|
||||
}
|
||||
|
||||
_uiHandler?.invoke(this);
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnderlyingComment(): IPlatformComment? {
|
||||
return _commentLoaded;
|
||||
}
|
||||
|
||||
fun setUIHandler(handler: (LazyComment)->Unit){
|
||||
_uiHandler = handler;
|
||||
}
|
||||
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
return _commentLoaded?.getReplies(client);
|
||||
}
|
||||
|
||||
}
|
|
@ -8,10 +8,12 @@ enum class ContentType(val value: Int) {
|
|||
POST(2),
|
||||
ARTICLE(3),
|
||||
PLAYLIST(4),
|
||||
WEB(7),
|
||||
|
||||
URL(9),
|
||||
|
||||
NESTED_VIDEO(11),
|
||||
CHANNEL(60),
|
||||
|
||||
LOCKED(70),
|
||||
|
||||
|
@ -21,7 +23,7 @@ enum class ContentType(val value: Int) {
|
|||
companion object {
|
||||
fun fromInt(value: Int): ContentType
|
||||
{
|
||||
val result = ContentType.values().firstOrNull { it.value == value };
|
||||
val result = ContentType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw UnknownPlatformException(value.toString());
|
||||
return result;
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.contents
|
|||
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
interface IPlatformContent {
|
||||
|
|
|
@ -2,14 +2,17 @@ package com.futo.platformplayer.api.media.models.live
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
val type : LiveEventType;
|
||||
var time: Long;
|
||||
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
|
||||
obj.ensureIsBusy();
|
||||
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
|
||||
|
|
|
@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
|||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -17,16 +18,21 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||
val colorName: String?;
|
||||
val badges: List<String>;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
this.thumbnail = thumbnail;
|
||||
this.colorName = colorName;
|
||||
this.badges = badges ?: listOf();
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment {
|
||||
obj.ensureIsBusy();
|
||||
|
||||
val contextName = "LiveEventComment"
|
||||
|
||||
val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null);
|
||||
|
@ -36,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||
obj.getOrThrow(config, "name", contextName),
|
||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||
obj.getOrThrow(config, "message", contextName),
|
||||
colorName, badges);
|
||||
colorName, badges,
|
||||
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -20,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||
|
||||
var expire: Int = 6000;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||
this.name = name;
|
||||
|
@ -37,6 +40,7 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventDonation"
|
||||
return LiveEventDonation(
|
||||
obj.getOrThrow(config, "name", contextName),
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventEmojis: IPlatformLiveEvent {
|
||||
|
@ -9,15 +10,17 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
|||
|
||||
val emojis: HashMap<String, String>;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(emojis: HashMap<String, String>) {
|
||||
this.emojis = emojis;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventEmojis"
|
||||
return LiveEventEmojis(
|
||||
obj.getOrThrow(config, "emojis", contextName));
|
||||
return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.live
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventRaid: IPlatformLiveEvent {
|
||||
|
@ -10,20 +12,26 @@ class LiveEventRaid: IPlatformLiveEvent {
|
|||
val targetName: String;
|
||||
val targetThumbnail: String;
|
||||
val targetUrl: String;
|
||||
val isOutgoing: Boolean;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String) {
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||
this.targetName = name;
|
||||
this.targetUrl = url;
|
||||
this.targetThumbnail = thumbnail;
|
||||
this.isOutgoing = isOutgoing;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventRaid"
|
||||
return LiveEventRaid(
|
||||
obj.getOrThrow(config, "targetName", contextName),
|
||||
obj.getOrThrow(config, "targetUrl", contextName),
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName));
|
||||
obj.getOrThrow(config, "targetThumbnail", contextName),
|
||||
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
|
|||
|
||||
companion object{
|
||||
fun fromInt(value : Int) : LiveEventType{
|
||||
return LiveEventType.values().first { it.value == value };
|
||||
return LiveEventType.entries.first { it.value == value };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class LiveEventViewCount: IPlatformLiveEvent {
|
||||
|
@ -9,12 +10,15 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
|||
|
||||
val viewCount: Int;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(viewCount: Int) {
|
||||
this.viewCount = viewCount;
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount {
|
||||
obj.ensureIsBusy();
|
||||
val contextName = "LiveEventViewCount"
|
||||
return LiveEventViewCount(
|
||||
obj.getOrThrow(config, "viewCount", contextName));
|
||||
|
|
|
@ -5,12 +5,13 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
|||
enum class TextType(val value: Int) {
|
||||
RAW(0),
|
||||
HTML(1),
|
||||
MARKUP(2);
|
||||
MARKUP(2),
|
||||
CODE(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): TextType
|
||||
{
|
||||
val result = TextType.values().firstOrNull { it.value == value };
|
||||
val result = TextType.entries.firstOrNull { it.value == value };
|
||||
if(result == null)
|
||||
throw IllegalArgumentException("Unknown Texttype: $value");
|
||||
return result;
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orDefault
|
||||
import com.futo.platformplayer.serializers.IRatingSerializer
|
||||
|
@ -13,8 +14,12 @@ interface IRating {
|
|||
|
||||
|
||||
companion object {
|
||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) };
|
||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orDefault(default) { fromV8(config, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
|
||||
obj.ensureIsBusy();
|
||||
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||
return when(t) {
|
||||
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
|
@ -14,6 +15,7 @@ class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes {
|
||||
obj.ensureIsBusy();
|
||||
return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
|
@ -13,6 +14,7 @@ class RatingLikes(val likes: Long) : IRating {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes {
|
||||
obj.ensureIsBusy();
|
||||
return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
/**
|
||||
|
@ -13,6 +14,7 @@ class RatingScaler(val value: Float) : IRating {
|
|||
|
||||
companion object {
|
||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler {
|
||||
obj.ensureIsBusy()
|
||||
return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
|
|||
|
||||
companion object{
|
||||
fun fromInt(value : Int) : RatingType{
|
||||
return RatingType.values().first { it.value == value };
|
||||
return RatingType.entries.first { it.value == value };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ package com.futo.platformplayer.api.media.models.streams
|
|||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
class LocalVideoMuxedSourceDescriptor(
|
||||
class DownloadedVideoMuxedSourceDescriptor(
|
||||
private val video: VideoLocal
|
||||
) : VideoMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
|
@ -13,7 +13,8 @@ class AudioUrlSource(
|
|||
override val codec: String = "",
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
@ -36,7 +37,9 @@ class AudioUrlSource(
|
|||
source.container,
|
||||
source.codec,
|
||||
source.language,
|
||||
source.duration
|
||||
source.duration,
|
||||
source.priority,
|
||||
source.original
|
||||
);
|
||||
ret.streamMetaData = streamData;
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ class HLSVariantAudioUrlSource(
|
|||
override val language: String,
|
||||
override val duration: Long?,
|
||||
override val priority: Boolean,
|
||||
override val original: Boolean,
|
||||
val url: String
|
||||
) : IAudioUrlSource {
|
||||
override fun getAudioUrl(): String {
|
||||
|
|
|
@ -8,4 +8,5 @@ interface IAudioSource {
|
|||
val language : String;
|
||||
val duration : Long?;
|
||||
val priority: Boolean;
|
||||
val original: Boolean;
|
||||
}
|
|
@ -1,6 +1,3 @@
|
|||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IAudioUrlWidevineSource : IAudioUrlSource {
|
||||
val bearerToken: String
|
||||
val licenseUri: String
|
||||
}
|
||||
interface IAudioUrlWidevineSource : IAudioUrlSource, IWidevineSource
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IDashManifestWidevineSource : IWidevineSource {
|
||||
val url: String
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
interface IVideoUrlWidevineSource : IVideoUrlSource, IWidevineSource
|
|
@ -0,0 +1,9 @@
|
|||
package com.futo.platformplayer.api.media.models.streams.sources
|
||||
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
|
||||
interface IWidevineSource {
|
||||
val licenseUri: String
|
||||
val hasLicenseRequestExecutor: Boolean
|
||||
fun getLicenseRequestExecutor(): JSRequestExecutor?
|
||||
}
|
|
@ -15,6 +15,7 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
|
|||
override val duration: Long? = null;
|
||||
|
||||
override var priority: Boolean = false;
|
||||
override val original: Boolean = false;
|
||||
|
||||
val filePath : String;
|
||||
val fileSize: Long;
|
||||
|
@ -33,13 +34,13 @@ class LocalAudioSource : IAudioSource, IStreamMetaDataSource {
|
|||
}
|
||||
|
||||
companion object {
|
||||
fun fromSource(source: IAudioSource, path: String, fileSize: Long): LocalAudioSource {
|
||||
fun fromSource(source: IAudioSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalAudioSource {
|
||||
return LocalAudioSource(
|
||||
source.name,
|
||||
path,
|
||||
fileSize,
|
||||
source.bitrate,
|
||||
source.container,
|
||||
overrideContainer ?: source.container,
|
||||
source.codec,
|
||||
source.language
|
||||
);
|
||||
|
|
|
@ -35,7 +35,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
|||
}
|
||||
|
||||
companion object {
|
||||
fun fromSource(source: IVideoSource, path: String, fileSize: Long): LocalVideoSource {
|
||||
fun fromSource(source: IVideoSource, path: String, fileSize: Long, overrideContainer: String? = null): LocalVideoSource {
|
||||
return LocalVideoSource(
|
||||
source.name,
|
||||
path,
|
||||
|
@ -43,7 +43,7 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
|||
source.width,
|
||||
source.height,
|
||||
source.duration,
|
||||
source.container,
|
||||
overrideContainer ?: source.container,
|
||||
source.codec,
|
||||
source.bitrate?:0
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
|
|||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* A search result representing a video (overview data)
|
||||
|
@ -12,5 +13,10 @@ interface IPlatformVideo : IPlatformContent {
|
|||
val duration: Long;
|
||||
val viewCount: Long;
|
||||
|
||||
val playbackTime: Long;
|
||||
val playbackDate: OffsetDateTime?;
|
||||
|
||||
val isLive : Boolean;
|
||||
|
||||
val isShort: Boolean;
|
||||
}
|
|
@ -3,33 +3,39 @@ package com.futo.platformplayer.api.media.models.video
|
|||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class SerializedPlatformVideo(
|
||||
override val contentType: ContentType = ContentType.MEDIA,
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val thumbnails: Thumbnails = Thumbnails(),
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?,
|
||||
@JsonNames("datetime", "dateTime")
|
||||
override val datetime: OffsetDateTime? = null,
|
||||
override val url: String,
|
||||
override val shareUrl: String,
|
||||
override val shareUrl: String = "",
|
||||
|
||||
override val duration: Long,
|
||||
override val viewCount: Long,
|
||||
override val isShort: Boolean = false
|
||||
) : IPlatformVideo, SerializedPlatformContent {
|
||||
override val contentType: ContentType = ContentType.MEDIA;
|
||||
|
||||
override val isLive: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
|
@ -43,6 +49,7 @@ open class SerializedPlatformVideo(
|
|||
companion object {
|
||||
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
|
||||
return SerializedPlatformVideo(
|
||||
ContentType.MEDIA,
|
||||
video.id,
|
||||
video.name,
|
||||
video.thumbnails,
|
||||
|
|
|
@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
|||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -38,10 +37,15 @@ open class SerializedPlatformVideoDetails(
|
|||
override val video: ISerializedVideoSourceDescriptor,
|
||||
override val preview: ISerializedVideoSourceDescriptor?,
|
||||
|
||||
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||
override val subtitles: List<SubtitleRawSource> = listOf(),
|
||||
override val isShort: Boolean = false
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
|
|
|
@ -54,8 +54,12 @@ class DevJSClient : JSClient {
|
|||
return DevJSClient(context, config, _devScript, _auth, _captcha, devID, descriptor.settings);
|
||||
}
|
||||
|
||||
override fun getCopy(): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, _auth, _captcha, saveState(), devID);
|
||||
override fun getCopy(privateCopy: Boolean, noSaveState: Boolean): JSClient {
|
||||
val client = DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _captcha, if (noSaveState) null else saveState(), devID);
|
||||
client.setReloadData(getReloadData(true));
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
|
@ -22,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
|
@ -31,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannel
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelContentPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSChapter
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSComment
|
||||
|
@ -41,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
|
@ -57,9 +61,13 @@ import com.futo.platformplayer.states.AnnouncementType
|
|||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Random
|
||||
import kotlin.Exception
|
||||
import kotlin.reflect.full.findAnnotations
|
||||
import kotlin.reflect.jvm.kotlinFunction
|
||||
|
@ -81,6 +89,8 @@ open class JSClient : IPlatformClient {
|
|||
private var _channelCapabilities: ResultCapabilities? = null;
|
||||
private var _peekChannelTypes: List<String>? = null;
|
||||
|
||||
private var _usedReloadData: String? = null;
|
||||
|
||||
protected val _script: String;
|
||||
|
||||
private var _initialized: Boolean = false;
|
||||
|
@ -96,14 +106,14 @@ open class JSClient : IPlatformClient {
|
|||
override val icon: ImageVariable;
|
||||
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
|
||||
|
||||
private val _busyLock = Object();
|
||||
private var _busyCounter = 0;
|
||||
private var _busyAction = "";
|
||||
val isBusy: Boolean get() = _busyCounter > 0;
|
||||
val isBusy: Boolean get() = _plugin.isBusy;
|
||||
val isBusyAction: String get() {
|
||||
return _busyAction;
|
||||
}
|
||||
|
||||
val declareOnEnable = HashMap<String, String>();
|
||||
|
||||
val settings: HashMap<String, String?> get() = descriptor.settings;
|
||||
|
||||
val flags: Array<String>;
|
||||
|
@ -116,6 +126,7 @@ open class JSClient : IPlatformClient {
|
|||
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
|
@ -164,13 +175,16 @@ open class JSClient : IPlatformClient {
|
|||
|
||||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) {
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
||||
this._context = context;
|
||||
this.config = descriptor.config;
|
||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
_auth = descriptor.getAuth();
|
||||
if(!withoutCredentials)
|
||||
_auth = descriptor.getAuth();
|
||||
else
|
||||
_auth = null;
|
||||
_captcha = descriptor.getCaptchaData();
|
||||
flags = descriptor.flags.toTypedArray();
|
||||
|
||||
|
@ -190,8 +204,12 @@ open class JSClient : IPlatformClient {
|
|||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(): JSClient {
|
||||
return JSClient(_context, descriptor, saveState(), _script);
|
||||
open fun getCopy(withoutCredentials: Boolean = false, noSaveState: Boolean = false): JSClient {
|
||||
val client = JSClient(_context, descriptor, if (noSaveState) null else saveState(), _script, withoutCredentials);
|
||||
client.setReloadData(getReloadData(true));
|
||||
if (noSaveState)
|
||||
client.initialize()
|
||||
return client
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): V8Plugin {
|
||||
|
@ -205,12 +223,31 @@ open class JSClient : IPlatformClient {
|
|||
return plugin.httpClientOthers[id];
|
||||
}
|
||||
|
||||
fun setReloadData(data: String?) {
|
||||
if(data == null) {
|
||||
if(declareOnEnable.containsKey("__reloadData"))
|
||||
declareOnEnable.remove("__reloadData");
|
||||
}
|
||||
else
|
||||
declareOnEnable.put("__reloadData", data ?: "");
|
||||
}
|
||||
fun getReloadData(orLast: Boolean): String? {
|
||||
if(declareOnEnable.containsKey("__reloadData"))
|
||||
return declareOnEnable["__reloadData"];
|
||||
else if(orLast)
|
||||
return _usedReloadData;
|
||||
return null;
|
||||
}
|
||||
|
||||
override fun initialize() {
|
||||
Logger.i(TAG, "Plugin [${config.name}] initializing");
|
||||
if (_initialized) return
|
||||
|
||||
plugin.start();
|
||||
|
||||
plugin.execute("plugin.config = ${Json.encodeToString(config)}");
|
||||
plugin.execute("plugin.settings = parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())})");
|
||||
|
||||
|
||||
descriptor.appSettings.loadDefaults(descriptor.config);
|
||||
|
||||
_initialized = true;
|
||||
|
@ -234,7 +271,9 @@ open class JSClient : IPlatformClient {
|
|||
hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false,
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
|
@ -249,19 +288,28 @@ open class JSClient : IPlatformClient {
|
|||
}
|
||||
|
||||
@JSDocs(0, "source.enable()", "Called when the plugin is enabled/started")
|
||||
fun enable() {
|
||||
fun enable() = isBusyWith("enable") {
|
||||
if(!_initialized)
|
||||
initialize();
|
||||
for(toDeclare in declareOnEnable) {
|
||||
plugin.execute("var ${toDeclare.key} = " + Json.encodeToString(toDeclare.value));
|
||||
}
|
||||
plugin.execute("source.enable(${Json.encodeToString(config)}, parseSettings(${Json.encodeToString(descriptor.getSettingsWithDefaults())}), ${Json.encodeToString(_injectedSaveState)})");
|
||||
|
||||
if(declareOnEnable.containsKey("__reloadData")) {
|
||||
Logger.i(TAG, "Plugin [${config.name}] enabled with reload data: ${declareOnEnable["__reloadData"]}");
|
||||
_usedReloadData = declareOnEnable["__reloadData"];
|
||||
declareOnEnable.remove("__reloadData");
|
||||
}
|
||||
_enabled = true;
|
||||
}
|
||||
@JSDocs(1, "source.saveState()", "Provide a string that is passed to enable for quicker startup of multiple instances")
|
||||
fun saveState(): String? {
|
||||
fun saveState(): String? = isBusyWith("saveState") {
|
||||
ensureEnabled();
|
||||
if(!capabilities.hasSaveState)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
val resp = plugin.executeTyped<V8ValueString>("source.saveState()").value;
|
||||
return resp;
|
||||
return@isBusyWith resp;
|
||||
}
|
||||
|
||||
@JSDocs(1, "source.disable()", "Called before the plugin is disabled/stopped")
|
||||
|
@ -284,6 +332,13 @@ open class JSClient : IPlatformClient {
|
|||
plugin.executeTyped("source.getHome()"));
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
|
||||
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
|
||||
ensureEnabled()
|
||||
return@isBusyWith JSVideoPager(config, this,
|
||||
plugin.executeTyped("source.getShorts()"))
|
||||
}
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
|
@ -302,8 +357,10 @@ open class JSClient : IPlatformClient {
|
|||
return _searchCapabilities!!;
|
||||
}
|
||||
|
||||
_searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()"));
|
||||
return _searchCapabilities!!;
|
||||
return busy {
|
||||
_searchCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchCapabilities()"));
|
||||
return@busy _searchCapabilities!!;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getSearchCapabilities", ex);
|
||||
|
@ -331,8 +388,10 @@ open class JSClient : IPlatformClient {
|
|||
if (_searchChannelContentsCapabilities != null)
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
|
||||
_searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()"));
|
||||
return _searchChannelContentsCapabilities!!;
|
||||
return busy {
|
||||
_searchChannelContentsCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getSearchChannelContentsCapabilities()"));
|
||||
return@busy _searchChannelContentsCapabilities!!;
|
||||
}
|
||||
}
|
||||
@JSDocs(5, "source.searchChannelContents(query)", "Searches for videos on the platform")
|
||||
@JSDocsParameter("channelUrl", "Channel url to search")
|
||||
|
@ -357,17 +416,21 @@ open class JSClient : IPlatformClient {
|
|||
return@isBusyWith JSChannelPager(config, this,
|
||||
plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"));
|
||||
}
|
||||
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> = isBusyWith("searchChannels") {
|
||||
ensureEnabled();
|
||||
return@isBusyWith JSChannelContentPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})"), );
|
||||
}
|
||||
|
||||
@JSDocs(6, "source.isChannelUrl(url)", "Validates if an channel url is for this platform")
|
||||
@JSDocsParameter("url", "A channel url (May not be your platform)")
|
||||
override fun isChannelUrl(url: String): Boolean {
|
||||
override fun isChannelUrl(url: String): Boolean = isBusyWith("isChannelUrl") {
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})")
|
||||
return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isChannelUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isChannelUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url")
|
||||
|
@ -385,9 +448,10 @@ open class JSClient : IPlatformClient {
|
|||
if (_channelCapabilities != null) {
|
||||
return _channelCapabilities!!;
|
||||
}
|
||||
|
||||
_channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()"));
|
||||
return _channelCapabilities!!;
|
||||
return busy {
|
||||
_channelCapabilities = ResultCapabilities.fromV8(config, plugin.executeTyped("source.getChannelCapabilities()"));
|
||||
return@busy _channelCapabilities!!;
|
||||
};
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("getChannelCapabilities", ex);
|
||||
|
@ -498,14 +562,14 @@ open class JSClient : IPlatformClient {
|
|||
|
||||
@JSDocs(13, "source.isContentDetailsUrl(url)", "Validates if an content url is for this platform")
|
||||
@JSDocsParameter("url", "A content url (May not be your platform)")
|
||||
override fun isContentDetailsUrl(url: String): Boolean {
|
||||
override fun isContentDetailsUrl(url: String): Boolean = isBusyWith("isContentDetailsUrl") {
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})")
|
||||
return@isBusyWith plugin.executeTyped<V8ValueBoolean>("source.isContentDetailsUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isContentDetailsUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url")
|
||||
|
@ -537,7 +601,7 @@ open class JSClient : IPlatformClient {
|
|||
Logger.i(TAG, "JSClient.getPlaybackTracker(${url})");
|
||||
val tracker = plugin.executeTyped<V8Value>("source.getPlaybackTracker(${Json.encodeToString(url)})");
|
||||
if(tracker is V8ValueObject)
|
||||
return@isBusyWith JSPlaybackTracker(config, tracker);
|
||||
return@isBusyWith JSPlaybackTracker(this, tracker);
|
||||
else
|
||||
return@isBusyWith null;
|
||||
}
|
||||
|
@ -579,7 +643,6 @@ open class JSClient : IPlatformClient {
|
|||
plugin.executeTyped("source.getLiveEvents(${Json.encodeToString(url)})"));
|
||||
}
|
||||
|
||||
|
||||
@JSDocs(19, "source.getContentRecommendations(url)", "Gets recommendations of a content page")
|
||||
@JSDocsParameter("url", "Url of content")
|
||||
override fun getContentRecommendations(url: String): IPager<IPlatformContent>? = isBusyWith("getContentRecommendations") {
|
||||
|
@ -607,17 +670,19 @@ open class JSClient : IPlatformClient {
|
|||
@JSOptional
|
||||
@JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform")
|
||||
@JSDocsParameter("url", "Url of playlist")
|
||||
override fun isPlaylistUrl(url: String): Boolean {
|
||||
override fun isPlaylistUrl(url: String): Boolean = isBusyWith("isPlaylistUrl") {
|
||||
if (!capabilities.hasGetPlaylist)
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
|
||||
try {
|
||||
return plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
return@isBusyWith busy {
|
||||
return@busy plugin.executeTyped<V8ValueBoolean>("source.isPlaylistUrl(${Json.encodeToString(url)})")
|
||||
.value;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
announcePluginUnhandledException("isPlaylistUrl", ex);
|
||||
return false;
|
||||
return@isBusyWith false;
|
||||
}
|
||||
}
|
||||
@JSOptional
|
||||
|
@ -648,6 +713,13 @@ open class JSClient : IPlatformClient {
|
|||
.toTypedArray();
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||
ensureEnabled();
|
||||
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
try {
|
||||
plugin.start();
|
||||
|
@ -719,19 +791,29 @@ open class JSClient : IPlatformClient {
|
|||
return urls;
|
||||
}
|
||||
|
||||
|
||||
private fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
try {
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter++;
|
||||
}
|
||||
_busyAction = actionName;
|
||||
return handle();
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
return _plugin.busy {
|
||||
return@busy handle();
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
synchronized(_busyLock) {
|
||||
_busyCounter--;
|
||||
}
|
||||
fun <T> busyBlockingSuspended(handle: suspend ()->T): T {
|
||||
return _plugin.busy {
|
||||
return@busy runBlocking {
|
||||
return@runBlocking handle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> isBusyWith(actionName: String, handle: ()->T): T {
|
||||
//val busyId = kotlin.random.Random.nextInt(9999);
|
||||
return busy {
|
||||
try {
|
||||
_busyAction = actionName;
|
||||
return@busy handle();
|
||||
|
||||
}
|
||||
finally {
|
||||
_busyAction = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
class JSClientConstants {
|
||||
companion object {
|
||||
val PLUGIN_SPEC_VERSION = 2;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,10 @@ import android.net.Uri
|
|||
import com.futo.platformplayer.SignatureProvider
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import kotlinx.serialization.Contextual
|
||||
import java.net.URL
|
||||
import java.util.UUID
|
||||
|
||||
|
@ -31,6 +34,7 @@ class SourcePluginConfig(
|
|||
override val allowEval: Boolean = false,
|
||||
override val allowUrls: List<String> = listOf(),
|
||||
override val packages: List<String> = listOf(),
|
||||
override val packagesOptional: List<String> = listOf(),
|
||||
|
||||
val settings: List<Setting> = listOf(),
|
||||
|
||||
|
@ -44,10 +48,14 @@ class SourcePluginConfig(
|
|||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var enableInShorts: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
var allowAllHttpHeaderAccess: Boolean = false,
|
||||
var maxDownloadParallelism: Int = 0,
|
||||
var reduceFunctionsInLimitedVersion: Boolean = false,
|
||||
var changelog: HashMap<String, List<String>>? = null
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
|
@ -77,7 +85,8 @@ class SourcePluginConfig(
|
|||
private var _allowUrlsLowerVal: List<String>? = null;
|
||||
private val _allowUrlsLower: List<String> get() {
|
||||
if(_allowUrlsLowerVal == null)
|
||||
_allowUrlsLowerVal = allowUrls.map { it.lowercase() };
|
||||
_allowUrlsLowerVal = allowUrls.map { it.lowercase() }
|
||||
.filter { it.length > 0 };
|
||||
return _allowUrlsLowerVal!!;
|
||||
};
|
||||
|
||||
|
@ -96,6 +105,10 @@ class SourcePluginConfig(
|
|||
if(!packages.contains(pack))
|
||||
return false;
|
||||
}
|
||||
for(pack in newConfig.packagesOptional) {
|
||||
if(!packagesOptional.contains(pack))
|
||||
return false;
|
||||
}
|
||||
//Developer Submit Url should be same or empty
|
||||
if(!newConfig.developerSubmitUrl.isNullOrEmpty() && developerSubmitUrl != newConfig.developerSubmitUrl)
|
||||
return false;
|
||||
|
@ -124,7 +137,7 @@ class SourcePluginConfig(
|
|||
|
||||
val currentlyInstalledPlugin = StatePlugins.instance.getPlugin(id);
|
||||
if (currentlyInstalledPlugin != null) {
|
||||
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey) {
|
||||
if (currentlyInstalledPlugin.config.scriptPublicKey != scriptPublicKey && !currentlyInstalledPlugin.config.scriptPublicKey.isNullOrEmpty()) {
|
||||
list.add(Pair(
|
||||
"Different Author",
|
||||
"This plugin was signed by a different author. Please ensure that this is correct and that the plugin was not provided by a malicious actor."));
|
||||
|
@ -157,12 +170,17 @@ class SourcePluginConfig(
|
|||
}
|
||||
|
||||
fun validate(text: String): Boolean {
|
||||
if(scriptPublicKey.isNullOrEmpty())
|
||||
throw IllegalStateException("No public key present");
|
||||
if(scriptSignature.isNullOrEmpty())
|
||||
throw IllegalStateException("No signature present");
|
||||
try {
|
||||
if (scriptPublicKey.isNullOrEmpty())
|
||||
throw IllegalStateException("No public key present");
|
||||
if (scriptSignature.isNullOrEmpty())
|
||||
throw IllegalStateException("No signature present");
|
||||
|
||||
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
|
||||
return SignatureProvider.verify(text, scriptSignature, scriptPublicKey);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to verify due to an unhandled exception", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun isUrlAllowed(url: String): Boolean {
|
||||
|
@ -170,7 +188,20 @@ class SourcePluginConfig(
|
|||
return true;
|
||||
val uri = Uri.parse(url);
|
||||
val host = uri.host?.lowercase() ?: "";
|
||||
return _allowUrlsLower.any { it == host };
|
||||
return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
|
||||
}
|
||||
|
||||
fun getChangelogString(version: String): String?{
|
||||
if(changelog == null || !changelog!!.containsKey(version))
|
||||
return null;
|
||||
val changelog = changelog!![version]!!;
|
||||
if(changelog.size > 1) {
|
||||
return "Changelog (${version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||
}
|
||||
else if(changelog.size == 1) {
|
||||
return "Changelog (${version})\n" + changelog[0].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -180,6 +211,8 @@ class SourcePluginConfig(
|
|||
obj.sourceUrl = sourceUrl;
|
||||
return obj;
|
||||
}
|
||||
|
||||
private val TAG = "SourcePluginConfig"
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
|
|
|
@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
|
@ -103,12 +109,22 @@ class SourcePluginDescriptor {
|
|||
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
||||
var enableHome: Boolean? = null;
|
||||
|
||||
|
||||
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
||||
var enableSearch: Boolean? = null;
|
||||
|
||||
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
|
||||
var enableShorts: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
|
||||
var sync = Sync();
|
||||
@Serializable
|
||||
class Sync {
|
||||
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
|
||||
var enableHistorySync: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
|
@ -143,6 +159,8 @@ class SourcePluginDescriptor {
|
|||
tabEnabled.enableHome = config.enableInHome
|
||||
if(tabEnabled.enableSearch == null)
|
||||
tabEnabled.enableSearch = config.enableInSearch
|
||||
if(tabEnabled.enableShorts == null)
|
||||
tabEnabled.enableShorts = config.enableInShorts
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ class JSHttpClient : ManagedHttpClient {
|
|||
|
||||
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super(
|
||||
//Temporary ugly solution for DevPortal proxy support
|
||||
(if(jsClient?.config?.id == StateDeveloper.DEV_ID && StateDeveloper.instance.devProxy != null)
|
||||
(if((jsClient?.config?.id == StateDeveloper.DEV_ID || jsClient == null) && StateDeveloper.instance.devProxy != null)
|
||||
OkHttpClient.Builder().proxy(Proxy(Proxy.Type.HTTP,
|
||||
InetSocketAddress(StateDeveloper.instance.devProxy!!.url, StateDeveloper.instance.devProxy!!.port)
|
||||
))
|
||||
|
@ -67,6 +67,25 @@ class JSHttpClient : ManagedHttpClient {
|
|||
|
||||
}
|
||||
|
||||
fun resetAuthCookies() {
|
||||
_currentCookieMap.clear();
|
||||
if(!_auth?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in _auth!!.cookieMap!!)
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
if(!_captcha?.cookieMap.isNullOrEmpty()) {
|
||||
for(domainCookies in _captcha!!.cookieMap!!) {
|
||||
if(_currentCookieMap.containsKey(domainCookies.key))
|
||||
_currentCookieMap[domainCookies.key]?.putAll(domainCookies.value);
|
||||
else
|
||||
_currentCookieMap.put(domainCookies.key, HashMap(domainCookies.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
fun clearOtherCookies() {
|
||||
_otherCookieMap.clear();
|
||||
}
|
||||
|
||||
override fun clone(): ManagedHttpClient {
|
||||
val newClient = JSHttpClient(_jsClient, _auth);
|
||||
newClient._currentCookieMap = HashMap(_currentCookieMap.toList().associate { Pair(it.first, HashMap(it.second)) })
|
||||
|
@ -127,7 +146,7 @@ class JSHttpClient : ManagedHttpClient {
|
|||
}
|
||||
|
||||
if(doApplyCookies) {
|
||||
if (_currentCookieMap.isNotEmpty()) {
|
||||
if (_currentCookieMap.isNotEmpty() || _otherCookieMap.isNotEmpty()) {
|
||||
val cookiesToApply = hashMapOf<String, String>();
|
||||
synchronized(_currentCookieMap) {
|
||||
for(cookie in _currentCookieMap
|
||||
|
@ -135,6 +154,12 @@ class JSHttpClient : ManagedHttpClient {
|
|||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
};
|
||||
synchronized(_otherCookieMap) {
|
||||
for(cookie in _otherCookieMap
|
||||
.filter { domain.matchesDomain(it.key) }
|
||||
.flatMap { it.value.toList() })
|
||||
cookiesToApply[cookie.first] = cookie.second;
|
||||
}
|
||||
|
||||
if(cookiesToApply.size > 0) {
|
||||
val cookieString = cookiesToApply.map { it.key + "=" + it.value }.joinToString("; ");
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
|
@ -12,6 +14,7 @@ interface IJSContent: IPlatformContent {
|
|||
|
||||
companion object {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContent {
|
||||
obj.ensureIsBusy();
|
||||
val config = plugin.config;
|
||||
val type: Int = obj.getOrThrow(config, "contentType", "ContentItem");
|
||||
val pluginType: String? = obj.getOrDefault(config, "plugin_type", "ContentItem", null);
|
||||
|
@ -26,6 +29,9 @@ interface IJSContent: IPlatformContent {
|
|||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||
ContentType.CHANNEL -> JSChannelContent(config, obj);
|
||||
ContentType.ARTICLE -> JSArticle(config, obj);
|
||||
ContentType.WEB -> JSWeb(config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,16 +6,20 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
|||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IJSContentDetails: IPlatformContent {
|
||||
|
||||
companion object {
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject): IPlatformContentDetails {
|
||||
obj.ensureIsBusy();
|
||||
val type: Int = obj.getOrThrow(plugin.config, "contentType", "ContentDetails");
|
||||
return when(ContentType.fromInt(type)) {
|
||||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||
ContentType.WEB -> JSWebDetails(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
|
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