mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-08-03 06:40:19 +00:00
Compare commits
409 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 | ||
|
bd87a47551 |
||
|
bdae35b1a8 | ||
|
76103a2a8c |
||
|
f63f9dd6db |
||
|
470b7bd2e5 |
||
|
9014fb581d |
||
|
7ffa6b1bb3 |
||
|
3cd4b4503f |
||
|
d63fa521a1 |
||
|
ca781dfe15 |
||
|
4bc561ceab |
||
|
3d258180bd |
||
|
d5cab0910e |
||
|
d4ccf232c1 |
||
|
daf1d42a0f |
||
|
a1d460385d |
||
|
d2ed0c65ca |
368 changed files with 16271 additions and 5190 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
|
|
@ -1,6 +1,9 @@
|
|||
name: Bug Report
|
||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||
labels: ["Bug"]
|
||||
labels: ["Bug", "Android"]
|
||||
title: "Bug: "
|
||||
type: bug
|
||||
projects: ["futo-org/19"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
@ -18,11 +21,33 @@ body:
|
|||
* 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: what-happened
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
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
|
||||
|
||||
|
@ -31,7 +56,7 @@ body:
|
|||
attributes:
|
||||
label: Grayjay Version
|
||||
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
||||
placeholder: "242"
|
||||
placeholder: "311"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
@ -42,19 +67,23 @@ body:
|
|||
multiple: true
|
||||
options:
|
||||
- "All"
|
||||
- "Youtube"
|
||||
- "Odysee"
|
||||
- "Rumble"
|
||||
- "Kick"
|
||||
- "Twitch"
|
||||
- "PeerTube"
|
||||
- "Patreon"
|
||||
- "Nebula"
|
||||
- "Apple Podcasts"
|
||||
- "BiliBili (CN)"
|
||||
- "Bitchute"
|
||||
- "SoundCloud"
|
||||
- "Crunchyroll"
|
||||
- "CuriosityStream"
|
||||
- "Dailymotion"
|
||||
- "Apple Podcasts"
|
||||
- "Kick"
|
||||
- "Nebula"
|
||||
- "Odysee"
|
||||
- "Patreon"
|
||||
- "PeerTube"
|
||||
- "Rumble"
|
||||
- "SoundCloud"
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "Youtube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
@ -66,6 +95,30 @@ body:
|
|||
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:
|
||||
|
@ -86,9 +139,28 @@ body:
|
|||
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.
|
|
@ -1,13 +1,16 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or other enhancement.
|
||||
labels: ["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 relating to the Grayjay Android Application
|
||||
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)
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
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)
|
||||
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)
|
12
.gitmodules
vendored
12
.gitmodules
vendored
|
@ -94,3 +94,15 @@
|
|||
[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
|
||||
|
|
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.
|
@ -156,6 +156,7 @@ android {
|
|||
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
|
||||
|
@ -180,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'
|
||||
|
@ -197,7 +199,8 @@ dependencies {
|
|||
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:6.0-2.LTS'
|
||||
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
|
||||
}*/
|
|
@ -55,7 +55,7 @@
|
|||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
|
|
|
@ -32,7 +32,8 @@ let Type = {
|
|||
Text: {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
MARKUP: 2,
|
||||
CODE: 3
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
@ -102,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);
|
||||
|
@ -244,6 +251,9 @@ 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;
|
||||
}
|
||||
|
@ -291,15 +301,39 @@ class PlatformPostDetails extends PlatformPost {
|
|||
}
|
||||
}
|
||||
|
||||
class PlatformArticleDetails extends PlatformContent {
|
||||
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.summary = obj.summary ?? "";
|
||||
this.segments = obj.segments ?? [];
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class ArticleSegment {
|
||||
|
@ -315,9 +349,17 @@ class ArticleTextSegment extends ArticleSegment {
|
|||
}
|
||||
}
|
||||
class ArticleImagesSegment extends ArticleSegment {
|
||||
constructor(images) {
|
||||
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 {
|
||||
|
@ -595,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -666,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -743,6 +788,7 @@ let plugin = {
|
|||
//To override by plugin
|
||||
const source = {
|
||||
getHome() { return new ContentPager([], false, {}); },
|
||||
getShorts() { return new VideoPager([], false, {}); },
|
||||
|
||||
enable(config){ },
|
||||
disable() {},
|
||||
|
|
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()
|
|
@ -14,7 +14,6 @@ import java.text.DecimalFormat
|
|||
import java.time.OffsetDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
|
||||
|
@ -376,14 +375,19 @@ private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.a
|
|||
fun String.matchesDomain(queryDomain: String): Boolean {
|
||||
|
||||
if(queryDomain.startsWith(".")) {
|
||||
|
||||
val parts = queryDomain.lowercase().split(".");
|
||||
if(parts.size < 3)
|
||||
val parts = this.lowercase().split(".");
|
||||
val queryParts = queryDomain.lowercase().trimStart("."[0]).split(".");
|
||||
if(queryParts.size < 2)
|
||||
throw IllegalStateException("Illegal use of wildcards on First-Level-Domain (" + queryDomain + ")");
|
||||
if(parts.size >= 3){
|
||||
val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]);
|
||||
if(isSLD && parts.size <= 3)
|
||||
else {
|
||||
val possibleDomain = "." + queryParts.joinToString(".");
|
||||
if(slds.contains(possibleDomain))
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
/*
|
||||
val isSLD = slds.contains("." + queryParts[queryParts.size - 2] + "." + queryParts[queryParts.size - 1]);
|
||||
if(isSLD && queryParts.size <= 3)
|
||||
throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain (" + queryDomain + ")");
|
||||
*/
|
||||
}
|
||||
|
||||
//TODO: Should be safe, but double verify if can't be exploited
|
||||
|
@ -395,9 +399,11 @@ fun String.matchesDomain(queryDomain: String): Boolean {
|
|||
|
||||
fun String.getSubdomainWildcardQuery(): String {
|
||||
val domainParts = this.split(".");
|
||||
val sldParts = "." + domainParts[domainParts.size - 2].lowercase() + "." + domainParts[domainParts.size - 1].lowercase();
|
||||
if(slds.contains(sldParts))
|
||||
return "." + domainParts.drop(domainParts.size - 3).joinToString(".");
|
||||
var wildcardDomain = if(domainParts.size > 2)
|
||||
"." + domainParts.drop(1).joinToString(".")
|
||||
else
|
||||
return "." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||
"." + domainParts.joinToString(".");
|
||||
if(slds.contains(wildcardDomain.lowercase()))
|
||||
"." + domainParts.joinToString(".");
|
||||
return wildcardDomain;
|
||||
}
|
|
@ -216,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;
|
||||
|
@ -236,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());
|
||||
}
|
||||
|
||||
|
@ -245,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 {
|
||||
|
|
|
@ -7,6 +7,9 @@ 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?{
|
||||
|
@ -33,13 +36,37 @@ fun Boolean?.toYesNo(): String {
|
|||
fun InetAddress?.toUrlAddress(): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> {
|
||||
"[${hostAddress}]"
|
||||
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 -> {
|
||||
hostAddress
|
||||
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 -> {
|
||||
|
@ -147,3 +193,136 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
|||
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));
|
||||
}
|
|
@ -29,6 +29,7 @@ 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
|
||||
|
@ -175,6 +176,10 @@ class Settings : FragmentedStorageFileJson() {
|
|||
}
|
||||
}*/
|
||||
|
||||
|
||||
@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
|
||||
|
@ -221,10 +226,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@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;
|
||||
|
||||
|
@ -253,9 +259,11 @@ 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;
|
||||
|
||||
|
@ -277,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;
|
||||
}
|
||||
|
@ -302,16 +311,20 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@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;
|
||||
|
||||
|
@ -342,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;
|
||||
|
||||
|
@ -425,9 +441,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var preferredPreviewQuality: Int = 5;
|
||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||
var simplifySources: Boolean = true;
|
||||
|
||||
@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
|
||||
|
||||
|
@ -438,6 +456,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
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;
|
||||
|
@ -464,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;
|
||||
|
@ -497,8 +512,97 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@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)
|
||||
|
@ -514,6 +618,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@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;
|
||||
|
||||
|
@ -550,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;
|
||||
|
@ -583,10 +690,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var keepScreenOn: Boolean = true;
|
||||
|
||||
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 1)
|
||||
@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)
|
||||
|
@ -654,9 +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;
|
||||
|
||||
|
@ -857,7 +977,23 @@ 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)
|
||||
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||
fun viewLicenseStatus() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
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?", {
|
||||
|
@ -875,15 +1011,20 @@ class Settings : FragmentedStorageFileJson() {
|
|||
var other = Other();
|
||||
@Serializable
|
||||
class Other {
|
||||
@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, 4)
|
||||
@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, 5)
|
||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||
var polycentricLocalCache: Boolean = true;
|
||||
}
|
||||
|
||||
|
@ -921,7 +1062,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||
@Serializable
|
||||
class Synchronization {
|
||||
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||
var enabled: Boolean = true;
|
||||
var enabled: Boolean = false;
|
||||
|
||||
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||
var broadcast: Boolean = false;
|
||||
|
@ -931,6 +1072,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||
|
||||
@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)
|
||||
|
|
|
@ -5,6 +5,7 @@ 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
|
||||
|
@ -199,16 +200,21 @@ 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;
|
||||
|
@ -275,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) {
|
||||
|
@ -312,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
|
||||
|
@ -326,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)
|
||||
);
|
||||
}
|
||||
|
@ -409,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);
|
||||
|
@ -417,6 +432,7 @@ class UIDialogs {
|
|||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
|
@ -429,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();
|
||||
}
|
||||
|
|
|
@ -4,8 +4,14 @@ import android.app.NotificationManager
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
|
@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
|
|||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.models.SubscriptionGroup
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.parsers.HLS.MediaRendition
|
||||
import com.futo.platformplayer.parsers.HLS.StreamInfo
|
||||
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
|
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
|
@ -118,115 +129,163 @@ class UISlideOverlays {
|
|||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
val menu = SlideUpMenuOverlay(
|
||||
container.context,
|
||||
container,
|
||||
"Subscription Settings",
|
||||
null,
|
||||
true,
|
||||
listOf()
|
||||
);
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(
|
||||
listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications =
|
||||
menu?.selectOption(null, "notifications", true, true)
|
||||
?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()
|
||||
) else null,
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups =
|
||||
ArrayList<SubscriptionGroup>(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
|
||||
null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if (it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
it.id
|
||||
)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if (it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
actualGroup
|
||||
);
|
||||
groups.addAll(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()
|
||||
),
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive =
|
||||
menu?.selectOption(null, "fetchLive", true, true)
|
||||
?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams =
|
||||
menu?.selectOption(null, "fetchStreams", true, true)
|
||||
?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts =
|
||||
menu?.selectOption(null, "fetchPosts", true, true)
|
||||
?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
|
@ -234,61 +293,82 @@ class UISlideOverlays {
|
|||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||
showCreateSubscriptionGroup(container, subscription.channel);
|
||||
}, false)*/
|
||||
).filterNotNull());
|
||||
).filterNotNull()
|
||||
);
|
||||
|
||||
menu.setItems(items);
|
||||
menu.setItems(items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
if (subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if (subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if (subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if (subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if (subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
|
||||
if(subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
if (subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Enable 'Background Update' in settings for notifications to work"
|
||||
);
|
||||
|
||||
if(mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
return@subscribe;
|
||||
}
|
||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
||||
if(mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
if (mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(
|
||||
mainContext,
|
||||
R.drawable.ic_settings,
|
||||
"Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications",
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(
|
||||
mainContext,
|
||||
SettingsActivity::class.java
|
||||
);
|
||||
intent.putExtra(
|
||||
"query",
|
||||
mainContext.getString(R.string.background_update)
|
||||
);
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
return@subscribe;
|
||||
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Android notifications are disabled"
|
||||
);
|
||||
if (mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
menu.show();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show subscription overlay.", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -299,6 +379,7 @@ class UISlideOverlays {
|
|||
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||
|
@ -310,6 +391,8 @@ class UISlideOverlays {
|
|||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val resolvedPlaylistUrl = masterPlaylistResponse.url
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
|
@ -322,55 +405,103 @@ class UISlideOverlays {
|
|||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
try {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
|
||||
val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
|
||||
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
|
||||
.parse(sourceUrl.toUri(), inputStream)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
if (playlist is HlsMediaPlaylist) {
|
||||
if (source is IHLSManifestAudioSource) {
|
||||
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
if (audioButtons.isEmpty()){
|
||||
val estSize = VideoHelper.estimateSourceSize(variant);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
variant.name,
|
||||
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + variant.codec).trim(),
|
||||
tag = variant,
|
||||
call = {
|
||||
selectedAudioVariant = variant
|
||||
slideUpMenuOverlay.selectOption(audioButtons, variant)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
} else {
|
||||
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(variant);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
variant.name,
|
||||
"${variant.width}x${variant.height}",
|
||||
(prefix + variant.codec).trim(),
|
||||
tag = variant,
|
||||
call = {
|
||||
selectedVideoVariant = variant
|
||||
slideUpMenuOverlay.selectOption(videoButtons, variant)
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
} else if (playlist is HlsMultivariantPlaylist) {
|
||||
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
|
||||
|
||||
masterPlaylist.getAudioSources().forEach { it ->
|
||||
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
audioButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_music,
|
||||
it.name,
|
||||
listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedAudioVariant = it
|
||||
slideUpMenuOverlay.selectOption(audioButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
|
||||
/*masterPlaylist.getSubtitleSources().forEach { it ->
|
||||
subtitleButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.format).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
|
||||
selectedSubtitleVariant = it
|
||||
slideUpMenuOverlay.selectOption(subtitleButtons, it)
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}, false))
|
||||
}*/
|
||||
|
||||
masterPlaylist.getVideoSources().forEach {
|
||||
val estSize = VideoHelper.estimateSourceSize(it);
|
||||
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
|
||||
videoButtons.add(SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_movie,
|
||||
it.name,
|
||||
"${it.width}x${it.height}",
|
||||
(prefix + it.codec).trim(),
|
||||
tag = it,
|
||||
call = {
|
||||
selectedVideoVariant = it
|
||||
slideUpMenuOverlay.selectOption(videoButtons, it)
|
||||
if (audioButtons.isEmpty()){
|
||||
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
|
||||
}
|
||||
},
|
||||
invokeParent = false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
|
@ -398,11 +529,11 @@ class UISlideOverlays {
|
|||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (source is IHLSManifestSource) {
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null)
|
||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
|
||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else if (source is IHLSManifestAudioSource) {
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null)
|
||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
|
||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
|
@ -684,6 +815,10 @@ class UISlideOverlays {
|
|||
}
|
||||
}
|
||||
}
|
||||
if(!Settings.instance.downloads.shouldDownload()) {
|
||||
UIDialogs.appToast("Download will start when you're back on wifi.\n" +
|
||||
"(You can change this in settings)", true);
|
||||
}
|
||||
}
|
||||
};
|
||||
return menu.apply { show() };
|
||||
|
@ -980,26 +1115,30 @@ class UISlideOverlays {
|
|||
+ actions).filterNotNull()
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.add_to_queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_history,
|
||||
container.context.getString(R.string.add_to_history),
|
||||
"Mark as watched",
|
||||
tag = "history",
|
||||
call = { StateHistory.instance.markAsWatched(video); }),
|
||||
));
|
||||
));
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
playlistItems.add(SlideUpMenuItem(
|
||||
|
@ -1063,14 +1202,17 @@ class UISlideOverlays {
|
|||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
|
@ -1078,8 +1220,10 @@ class UISlideOverlays {
|
|||
call = {
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true))
|
||||
UIDialogs.appToast("Added to watch later", false);
|
||||
else
|
||||
UIDialogs.toast(container.context.getString(R.string.already_in_watch_later))
|
||||
}),
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
val playlistItems = arrayListOf<SlideUpMenuItem>();
|
||||
|
@ -1117,8 +1261,8 @@ class UISlideOverlays {
|
|||
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
|
|
@ -27,14 +27,23 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
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.nio.ByteOrder
|
||||
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 {
|
||||
|
@ -66,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")
|
||||
}
|
||||
|
@ -269,7 +285,7 @@ fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
|||
}
|
||||
}
|
||||
if(newIndex < 0)
|
||||
return originalArr.size;
|
||||
return newArr.size;
|
||||
else
|
||||
return newIndex;
|
||||
}
|
||||
|
@ -279,3 +295,167 @@ fun ByteBuffer.toUtf8String(): String {
|
|||
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)
|
|
@ -1,14 +1,15 @@
|
|||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.app.AlertDialog
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.VmPolicy
|
||||
|
@ -21,6 +22,7 @@ import android.widget.ImageView
|
|||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
@ -30,6 +32,7 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -38,7 +41,9 @@ import com.futo.platformplayer.UIDialogs
|
|||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
|
@ -57,6 +62,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsF
|
|||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
|
@ -65,7 +71,9 @@ import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragm
|
|||
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
|
@ -74,7 +82,6 @@ import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.ImportCache
|
||||
import com.futo.platformplayer.models.UrlVideoWithTime
|
||||
import com.futo.platformplayer.receivers.MediaButtonReceiver
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
|
@ -107,7 +114,7 @@ import java.io.PrintWriter
|
|||
import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
|
||||
|
@ -146,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
//Frags Main
|
||||
lateinit var _fragMainHome: HomeFragment;
|
||||
lateinit var _fragPostDetail: PostDetailFragment;
|
||||
lateinit var _fragArticleDetail: ArticleDetailFragment;
|
||||
lateinit var _fragWebDetail: WebDetailFragment;
|
||||
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
||||
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||
|
@ -161,6 +170,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
lateinit var _fragHistory: HistoryFragment;
|
||||
lateinit var _fragShorts: ShortsFragment;
|
||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||
lateinit var _fragDownloads: DownloadsFragment;
|
||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||
|
@ -175,7 +185,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
lateinit var _fragVideoDetail: VideoDetailFragment;
|
||||
|
||||
//State
|
||||
private val _queue: Queue<Pair<MainFragment, Any?>> = LinkedList();
|
||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||
lateinit var fragCurrent: MainFragment private set;
|
||||
private var _parameterCurrent: Any? = null;
|
||||
|
||||
|
@ -185,6 +195,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
private var _isVisible = true;
|
||||
private var _wasStopped = false;
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
|
@ -196,7 +209,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
lifecycleScope.launch {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -206,6 +219,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
}
|
||||
|
||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||
|
||||
constructor() : super() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setVmPolicy(
|
||||
|
@ -257,11 +272,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
@UnstableApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.i(TAG, "MainActivity Starting");
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope);
|
||||
Logger.w(TAG, "MainActivity Starting [$mainId]");
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiMode = getSystemService(UiModeManager::class.java)
|
||||
uiMode.setApplicationNightMode(UiModeManager.MODE_NIGHT_YES)
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
|
@ -269,7 +288,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
try {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
|
@ -313,8 +336,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||
_fragPostDetail = PostDetailFragment.newInstance();
|
||||
_fragArticleDetail = ArticleDetailFragment.newInstance();
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragShorts = ShortsFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
_fragDownloads = DownloadsFragment();
|
||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||
|
@ -354,22 +380,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainSubscriptionsFeed.setPreviewsEnabled(true);
|
||||
_fragContainerVideoDetail.visibility = View.INVISIBLE;
|
||||
updateSegmentPaddings();
|
||||
updatePrivateModeVisibility()
|
||||
};
|
||||
|
||||
|
||||
_buttonIncognito = findViewById(R.id.incognito_button);
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
updatePrivateModeVisibility()
|
||||
StateApp.instance.privateModeChanged.subscribe {
|
||||
//Messing with visibility causes some issues with layout ordering?
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
_privateModeEnabled = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_buttonIncognito.setOnClickListener {
|
||||
if (!StateApp.instance.privateMode)
|
||||
return@setOnClickListener;
|
||||
|
@ -386,19 +408,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
};
|
||||
_fragVideoDetail.onFullscreenChanged.subscribe {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
if (it) {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
} else {
|
||||
if (StateApp.instance.privateMode) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMaximized.subscribe {
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
StatePlayer.instance.also {
|
||||
|
@ -446,6 +465,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||
_fragArticleDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWebDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||
_fragHistory.topBar = _fragTopBarNavigation;
|
||||
_fragSourceDetail.topBar = _fragTopBarNavigation;
|
||||
|
@ -590,6 +611,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
)
|
||||
}
|
||||
|
||||
//startActivity(Intent(this, TestActivity::class.java))
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -613,8 +636,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
UIDialogs.toast(this, "No external file permissions\nExporting and auto backups will not work");
|
||||
}*/
|
||||
|
||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||
"Launching QR scanner",
|
||||
"Make sure your camera is enabled", null, -2,
|
||||
UIDialogs.Action("Close", {
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}));
|
||||
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
|
@ -630,21 +663,36 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun updatePrivateModeVisibility() {
|
||||
if (_privateModeEnabled && (_fragVideoDetail.state == State.CLOSED || !_pictureInPictureEnabled && !_isFullscreen)) {
|
||||
_buttonIncognito.elevation = 99f;
|
||||
_buttonIncognito.alpha = 1f;
|
||||
_buttonIncognito.translationY = if (_fragVideoDetail.state == State.MINIMIZED) -60.dp(resources).toFloat() else 0f
|
||||
} else {
|
||||
_buttonIncognito.elevation = -99f;
|
||||
_buttonIncognito.alpha = 0f;
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.v(TAG, "onResume")
|
||||
Logger.w(TAG, "onResume [$mainId]")
|
||||
_isVisible = true;
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Logger.v(TAG, "onPause")
|
||||
Logger.w(TAG, "onPause [$mainId]")
|
||||
_isVisible = false;
|
||||
|
||||
_qrCodeLoadingDialog?.dismiss()
|
||||
_qrCodeLoadingDialog = null
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Logger.v(TAG, "_wasStopped = true");
|
||||
Logger.w(TAG, "onStop [$mainId]");
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
|
@ -678,7 +726,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
}
|
||||
|
||||
"IMPORT_OPTIONS" -> {
|
||||
|
@ -696,11 +744,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
"Sources" -> {
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||
navigate(_fragMainSources);
|
||||
navigateWhenReady(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if (it is MainActivity) {
|
||||
|
@ -718,8 +766,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
runBlocking {
|
||||
handleUrlAll(targetData)
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -747,10 +799,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
startActivity(intent);
|
||||
} else if (url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||
} else if (url.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = url.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
navigateWhenReady(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -816,29 +868,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
return withContext(Dispatchers.IO) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
if (StatePlatform.instance.hasEnabledContentClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (position > 0)
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
else
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainRemotePlaylist, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
|
@ -1050,18 +1102,33 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
|
||||
_pictureInPictureEnabled = isInPictureInPictureMode
|
||||
updatePrivateModeVisibility()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy();
|
||||
Logger.v(TAG, "onDestroy")
|
||||
StateApp.instance.mainAppDestroyed(this);
|
||||
Logger.w(TAG, "onDestroy [$mainId]")
|
||||
StateApp.instance.mainAppDestroyed(this, mainId);
|
||||
}
|
||||
|
||||
inline fun <reified T> isFragmentActive(): Boolean {
|
||||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
|
@ -1123,7 +1190,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
||||
fragBeforeOverlay = fragCurrent;
|
||||
|
||||
|
||||
fragCurrent = segment;
|
||||
_parameterCurrent = parameter;
|
||||
}
|
||||
|
@ -1186,8 +1252,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
PlaylistFragment::class -> _fragMainPlaylist as T;
|
||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||
PostDetailFragment::class -> _fragPostDetail as T;
|
||||
ArticleDetailFragment::class -> _fragArticleDetail as T;
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
ShortsFragment::class -> _fragShorts as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
DownloadsFragment::class -> _fragDownloads as T;
|
||||
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -9,6 +9,8 @@ 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
|
||||
|
@ -29,6 +31,16 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
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()
|
||||
|
||||
|
@ -54,7 +66,6 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
val view = _viewMap[publicKey]
|
||||
if (!session.isAuthorized) {
|
||||
if (view != null) {
|
||||
_layoutDevices.removeView(view)
|
||||
_viewMap.remove(publicKey)
|
||||
}
|
||||
return@launch
|
||||
|
@ -89,6 +100,20 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
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() {
|
||||
|
@ -100,10 +125,12 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
|
||||
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||
val connected = session?.connected ?: false
|
||||
syncDeviceView.setLinkType(if (connected) LinkType.Local else LinkType.None)
|
||||
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) "Connected" else "Disconnected")
|
||||
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||
return syncDeviceView
|
||||
}
|
||||
|
||||
|
|
|
@ -83,6 +83,7 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
_layoutPairingSuccess.setOnClickListener {
|
||||
_layoutPairingSuccess.visibility = View.GONE
|
||||
finish()
|
||||
}
|
||||
_layoutPairingError.setOnClickListener {
|
||||
_layoutPairingError.visibility = View.GONE
|
||||
|
@ -109,11 +110,17 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
StateSync.instance.connect(deviceInfo) { session, complete, message ->
|
||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
if (complete) {
|
||||
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||
_layoutPairing.visibility = View.GONE
|
||||
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
|
||||
}
|
||||
|
@ -137,8 +144,6 @@ class SyncPairActivity : AppCompatActivity() {
|
|||
_textError.text = e.message
|
||||
_layoutPairing.visibility = View.GONE
|
||||
Logger.e(TAG, "Failed to pair", e)
|
||||
} finally {
|
||||
_layoutPairing.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -67,11 +67,18 @@ class SyncShowPairingCodeActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
val ips = getIPs()
|
||||
val selfDeviceInfo = SyncDeviceInfo(StateSync.instance.publicKey!!, ips.toTypedArray(), StateSync.PORT)
|
||||
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)
|
||||
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?) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -90,6 +90,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun tryHead(url: String): Map<String, String>? {
|
||||
ensureNotMainThread()
|
||||
try {
|
||||
val result = head(url);
|
||||
if(result.isOk)
|
||||
|
@ -104,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" })
|
||||
|
@ -300,6 +301,7 @@ open class ManagedHttpClient {
|
|||
}
|
||||
|
||||
fun send(msg: String) {
|
||||
ensureNotMainThread()
|
||||
socket.send(msg);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
||||
}
|
|
@ -14,14 +14,16 @@ class PlatformClientPool {
|
|||
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, privatePool: Boolean = false) {
|
||||
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");
|
||||
|
@ -32,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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -53,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(_privatePool);
|
||||
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),
|
||||
|
|
|
@ -7,13 +7,15 @@ class PlatformMultiClientPool {
|
|||
|
||||
private var _isFake = false;
|
||||
private var _privatePool = false;
|
||||
private val _isolatedInitialization: Boolean
|
||||
|
||||
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false) {
|
||||
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 {
|
||||
|
@ -21,7 +23,7 @@ class PlatformMultiClientPool {
|
|||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool).apply {
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||
this.onDead.subscribe { _, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -30,6 +34,7 @@ open class PlatformAuthorLink {
|
|||
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);
|
||||
|
||||
|
@ -43,3 +48,20 @@ 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
|
||||
|
@ -46,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"); },
|
||||
|
@ -69,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)
|
||||
|
@ -90,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;
|
||||
}
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,7 +5,8 @@ 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
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
|
|||
val duration: Long;
|
||||
val viewCount: Long;
|
||||
|
||||
val playbackTime: Long;
|
||||
val playbackDate: OffsetDateTime?;
|
||||
|
||||
val isLive : Boolean;
|
||||
|
||||
val isShort: Boolean;
|
||||
|
|
|
@ -3,11 +3,10 @@ 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
|
||||
|
@ -18,7 +17,7 @@ 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)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
|
@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
|
|||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
|
|||
) : 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(privateCopy: Boolean): JSClient {
|
||||
return DevJSClient(_context, descriptor, _script, if(!privateCopy) _auth else null, _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;
|
||||
|
@ -193,8 +204,12 @@ open class JSClient : IPlatformClient {
|
|||
_plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit);
|
||||
}
|
||||
|
||||
open fun getCopy(withoutCredentials: Boolean = false): JSClient {
|
||||
return JSClient(_context, descriptor, saveState(), _script, withoutCredentials);
|
||||
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 {
|
||||
|
@ -208,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;
|
||||
|
@ -238,7 +272,8 @@ open class JSClient : IPlatformClient {
|
|||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
|
@ -253,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")
|
||||
|
@ -288,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") {
|
||||
|
@ -306,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);
|
||||
|
@ -335,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")
|
||||
|
@ -361,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")
|
||||
|
@ -389,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);
|
||||
|
@ -502,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")
|
||||
|
@ -541,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;
|
||||
}
|
||||
|
@ -583,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") {
|
||||
|
@ -611,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
|
||||
|
@ -652,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();
|
||||
|
@ -723,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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
@ -47,6 +48,7 @@ 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,
|
||||
|
@ -168,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 {
|
||||
|
@ -204,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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,17 +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}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
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.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
@ -19,22 +21,23 @@ import com.futo.platformplayer.api.media.structures.IPager
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
|
||||
val rating: IRating;
|
||||
override val rating: IRating;
|
||||
|
||||
val summary: String;
|
||||
val thumbnails: Thumbnails?;
|
||||
val segments: List<IJSArticleSegment>;
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformPost";
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
|
@ -83,12 +86,12 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
|
|||
}
|
||||
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
@ -99,6 +102,7 @@ open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails
|
|||
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
||||
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
||||
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||
else -> null;
|
||||
}
|
||||
|
@ -110,6 +114,7 @@ enum class SegmentType(val value: Int) {
|
|||
UNKNOWN(0),
|
||||
TEXT(1),
|
||||
IMAGES(2),
|
||||
HEADER(3),
|
||||
|
||||
NESTED(9);
|
||||
|
||||
|
@ -150,6 +155,17 @@ class JSImagesSegment: IJSArticleSegment {
|
|||
caption = obj.getOrDefault(client.config, "caption", contextName, "") ?: "";
|
||||
}
|
||||
}
|
||||
class JSHeaderSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.HEADER;
|
||||
val content: String;
|
||||
val level: Int;
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject) {
|
||||
val contextName = "JSHeaderSegment";
|
||||
content = obj.getOrDefault(client.config, "content", contextName, "") ?: "";
|
||||
level = obj.getOrDefault(client.config, "level", contextName, 1) ?: 1;
|
||||
}
|
||||
}
|
||||
class JSNestedSegment: IJSArticleSegment {
|
||||
override val type = SegmentType.NESTED;
|
||||
val nested: IPlatformContent;
|
||||
|
|
|
@ -5,7 +5,6 @@ import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
|||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
|
||||
class JSChannelPager : JSPager<PlatformAuthorLink>, IPager<PlatformAuthorLink> {
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -60,7 +61,7 @@ class JSComment : IPlatformComment {
|
|||
if(!_hasGetReplies)
|
||||
return null;
|
||||
|
||||
val obj = _comment!!.invoke<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
||||
return JSCommentPager(_config!!, plugin, obj);
|
||||
}
|
||||
|
|
|
@ -49,8 +49,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
|||
else
|
||||
author = PlatformAuthorLink.UNKNOWN;
|
||||
|
||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||
if(datetimeInt == 0.toLong())
|
||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||
datetime = null;
|
||||
else
|
||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.JSChannelContent
|
||||
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
|
||||
|
@ -16,3 +17,13 @@ class JSContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
|||
return IJSContent.fromV8(plugin, obj);
|
||||
}
|
||||
}
|
||||
|
||||
class JSChannelContentPager : JSPager<IPlatformContent>, IPluginSourced {
|
||||
override val sourceConfig: SourcePluginConfig get() = config;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {}
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformContent {
|
||||
return JSChannelContent(config, obj);
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ class JSLiveEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
|||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
override fun nextPage() = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
super.nextPage();
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
|||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
abstract class JSPager<T> : IPager<T> {
|
||||
|
@ -18,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
|
|||
protected var pager: V8ValueObject;
|
||||
|
||||
private var _lastResults: List<T>? = null;
|
||||
private var _resultChanged: Boolean = true;
|
||||
private var _hasMorePages: Boolean = false;
|
||||
protected var _resultChanged: Boolean = true;
|
||||
protected var _hasMorePages: Boolean = false;
|
||||
//private var _morePagesWasFalse: Boolean = false;
|
||||
|
||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
|
@ -29,7 +30,9 @@ abstract class JSPager<T> : IPager<T> {
|
|||
this.pager = pager;
|
||||
this.config = config;
|
||||
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
plugin.busy {
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
}
|
||||
getResults();
|
||||
}
|
||||
|
||||
|
@ -38,17 +41,20 @@ abstract class JSPager<T> : IPager<T> {
|
|||
}
|
||||
|
||||
override fun hasMorePages(): Boolean {
|
||||
return _hasMorePages;
|
||||
return _hasMorePages && !pager.isClosed;
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
warnIfMainThread("JSPager.nextPage");
|
||||
|
||||
pager = plugin.getUnderlyingPlugin().catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
pager = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invokeV8("nextPage", arrayOf<Any>());
|
||||
};
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
/*
|
||||
try {
|
||||
}
|
||||
|
@ -70,15 +76,18 @@ abstract class JSPager<T> : IPager<T> {
|
|||
return previousResults;
|
||||
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
if(items.v8Runtime.isDead || items.v8Runtime.isClosed)
|
||||
throw IllegalStateException("Runtime closed");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
.toList();
|
||||
_lastResults = newResults;
|
||||
_resultChanged = false;
|
||||
return newResults;
|
||||
|
||||
return plugin.getUnderlyingPlugin().busy {
|
||||
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
|
||||
if (items.v8Runtime.isDead || items.v8Runtime.isClosed)
|
||||
throw IllegalStateException("Runtime closed");
|
||||
val newResults = items.toArray()
|
||||
.map { convertResult(it as V8ValueObject) }
|
||||
.toList();
|
||||
_lastResults = newResults;
|
||||
_resultChanged = false;
|
||||
return@busy newResults;
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun convertResult(obj: V8ValueObject): T;
|
||||
|
|
|
@ -2,37 +2,51 @@ package com.futo.platformplayer.api.media.platforms.js.models
|
|||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
class JSPlaybackTracker: IPlaybackTracker {
|
||||
private val _config: IV8PluginConfig;
|
||||
private val _obj: V8ValueObject;
|
||||
private lateinit var _client: JSClient;
|
||||
private lateinit var _config: IV8PluginConfig;
|
||||
private lateinit var _obj: V8ValueObject;
|
||||
|
||||
private var _hasCalledInit: Boolean = false;
|
||||
private val _hasInit: Boolean;
|
||||
private var _hasInit: Boolean = false;
|
||||
|
||||
private var _lastRequest: Long = Long.MIN_VALUE;
|
||||
|
||||
private val _hasOnConcluded: Boolean;
|
||||
private var _hasOnConcluded: Boolean = false;
|
||||
|
||||
override var nextRequest: Int = 1000
|
||||
private set;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
constructor(client: JSClient, obj: V8ValueObject) {
|
||||
warnIfMainThread("JSPlaybackTracker.constructor");
|
||||
if(!obj.has("onProgress"))
|
||||
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
|
||||
if(!obj.has("nextRequest"))
|
||||
throw ScriptImplementationException(config, "Missing nextRequest on PlaybackTracker");
|
||||
_hasOnConcluded = obj.has("onConcluded");
|
||||
|
||||
this._config = config;
|
||||
this._obj = obj;
|
||||
this._hasInit = obj.has("onInit");
|
||||
client.busy {
|
||||
if (!obj.has("onProgress"))
|
||||
throw ScriptImplementationException(
|
||||
client.config,
|
||||
"Missing onProgress on PlaybackTracker"
|
||||
);
|
||||
if (!obj.has("nextRequest"))
|
||||
throw ScriptImplementationException(
|
||||
client.config,
|
||||
"Missing nextRequest on PlaybackTracker"
|
||||
);
|
||||
_hasOnConcluded = obj.has("onConcluded");
|
||||
|
||||
this._client = client;
|
||||
this._config = client.config;
|
||||
this._obj = obj;
|
||||
this._hasInit = obj.has("onInit");
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit(seconds: Double) {
|
||||
|
@ -40,12 +54,15 @@ class JSPlaybackTracker: IPlaybackTracker {
|
|||
synchronized(_obj) {
|
||||
if(_hasCalledInit)
|
||||
return;
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeVoid("onInit", seconds);
|
||||
|
||||
_client.busy {
|
||||
if (_hasInit) {
|
||||
Logger.i("JSPlaybackTracker", "onInit (${seconds})");
|
||||
_obj.invokeV8Void("onInit", seconds);
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
}
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_hasCalledInit = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,10 +72,12 @@ class JSPlaybackTracker: IPlaybackTracker {
|
|||
if(!_hasCalledInit && _hasInit)
|
||||
onInit(seconds);
|
||||
else {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeVoid("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
_client.busy {
|
||||
Logger.i("JSPlaybackTracker", "onProgress (${seconds}, ${isPlaying})");
|
||||
_obj.invokeV8Void("onProgress", Math.floor(seconds), isPlaying);
|
||||
nextRequest = Math.max(100, _obj.getOrThrow(_config, "nextRequest", "PlaybackTracker", false));
|
||||
_lastRequest = System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +86,9 @@ class JSPlaybackTracker: IPlaybackTracker {
|
|||
if(_hasOnConcluded) {
|
||||
synchronized(_obj) {
|
||||
Logger.i("JSPlaybackTracker", "onConcluded");
|
||||
_obj.invokeVoid("onConcluded", -1);
|
||||
_client.busy {
|
||||
_obj.invokeV8Void("onConcluded", -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
||||
|
@ -68,12 +69,12 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
|||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||
val commentPager = _content.invoke<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,8 @@ import com.futo.platformplayer.engine.exceptions.ScriptException
|
|||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.Serializable
|
||||
|
@ -46,52 +48,55 @@ class JSRequestExecutor {
|
|||
if (_executor.isClosed)
|
||||
throw IllegalStateException("Executor object is closed");
|
||||
|
||||
val result = if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
return _plugin.getUnderlyingPlugin().busy {
|
||||
|
||||
val result = if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invoke("executeRequest", url, headers, method, body);
|
||||
_executor.invokeV8("executeRequest", url, headers, method, body);
|
||||
} as V8Value;
|
||||
|
||||
try {
|
||||
if(result is V8ValueString) {
|
||||
val base64Result = Base64.getDecoder().decode(result.value);
|
||||
return base64Result;
|
||||
}
|
||||
if(result is V8ValueTypedArray) {
|
||||
val buffer = result.buffer;
|
||||
val byteBuffer = buffer.byteBuffer;
|
||||
val bytesResult = ByteArray(result.byteLength);
|
||||
byteBuffer.get(bytesResult, 0, result.byteLength);
|
||||
buffer.close();
|
||||
return bytesResult;
|
||||
}
|
||||
if(result is V8ValueObject && result.has("type")) {
|
||||
val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
|
||||
when(type) {
|
||||
//TODO: Buffer type?
|
||||
try {
|
||||
if(result is V8ValueString) {
|
||||
val base64Result = Base64.getDecoder().decode(result.value);
|
||||
return@busy base64Result;
|
||||
}
|
||||
if(result is V8ValueTypedArray) {
|
||||
val buffer = result.buffer;
|
||||
val byteBuffer = buffer.byteBuffer;
|
||||
val bytesResult = ByteArray(result.byteLength);
|
||||
byteBuffer.get(bytesResult, 0, result.byteLength);
|
||||
buffer.close();
|
||||
return@busy bytesResult;
|
||||
}
|
||||
if(result is V8ValueObject && result.has("type")) {
|
||||
val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
|
||||
when(type) {
|
||||
//TODO: Buffer type?
|
||||
}
|
||||
}
|
||||
if(result is V8ValueUndefined) {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
|
||||
throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
|
||||
}
|
||||
throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
|
||||
}
|
||||
if(result is V8ValueUndefined) {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
|
||||
throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
|
||||
finally {
|
||||
result.close();
|
||||
}
|
||||
throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
|
||||
}
|
||||
finally {
|
||||
result.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,24 +104,25 @@ class JSRequestExecutor {
|
|||
open fun cleanup() {
|
||||
if (!hasCleanup || _executor.isClosed)
|
||||
return;
|
||||
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeVoid("cleanup", null);
|
||||
};
|
||||
_plugin.busy {
|
||||
if(_plugin is DevJSClient)
|
||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||
V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("cleanup", null);
|
||||
};
|
||||
}
|
||||
else V8Plugin.catchScriptErrors<Any>(
|
||||
_config,
|
||||
"[${_config.name}] JSRequestExecutor",
|
||||
"builder.modifyRequest()"
|
||||
) {
|
||||
_executor.invokeV8("cleanup", null);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
|
|
|
@ -11,12 +11,14 @@ import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSRequestModifier: IRequestModifier {
|
||||
private val _plugin: JSClient;
|
||||
private val _config: IV8PluginConfig;
|
||||
private var _modifier: V8ValueObject;
|
||||
override var allowByteSkip: Boolean;
|
||||
override var allowByteSkip: Boolean = false;
|
||||
|
||||
constructor(plugin: JSClient, modifier: V8ValueObject) {
|
||||
this._plugin = plugin;
|
||||
|
@ -24,10 +26,13 @@ class JSRequestModifier: IRequestModifier {
|
|||
this._config = plugin.config;
|
||||
val config = plugin.config;
|
||||
|
||||
allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true;
|
||||
plugin.busy {
|
||||
allowByteSkip = modifier.getOrNull(config, "allowByteSkip", "JSRequestModifier") ?: true;
|
||||
|
||||
if(!modifier.has("modifyRequest"))
|
||||
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
|
||||
}
|
||||
|
||||
if(!modifier.has("modifyRequest"))
|
||||
throw ScriptImplementationException(config, "RequestModifier is missing modifyRequest", null);
|
||||
}
|
||||
|
||||
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||
|
@ -35,13 +40,15 @@ class JSRequestModifier: IRequestModifier {
|
|||
return Request(url, headers);
|
||||
}
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invoke("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
return _plugin.busy {
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||
_modifier.invokeV8("modifyRequest", url, headers);
|
||||
} as V8ValueObject;
|
||||
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return req;
|
||||
val req = JSRequest(_plugin, result, url, headers);
|
||||
result.close();
|
||||
return@busy req;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -35,8 +37,11 @@ class JSSubtitleSource : ISubtitleSource {
|
|||
override fun getSubtitles(): String {
|
||||
if(!hasFetch)
|
||||
throw IllegalStateException("This subtitle doesn't support getSubtitles..");
|
||||
val v8String = _obj.invoke<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return v8String.value;
|
||||
|
||||
return _obj.getSourcePlugin()?.busy {
|
||||
val v8String = _obj.invokeV8<V8ValueString>("getSubtitles", arrayOf<Any>());
|
||||
return@busy v8String.value;
|
||||
} ?: "";
|
||||
}
|
||||
|
||||
override suspend fun getSubtitlesURI(): Uri? {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
override var nextRequest: Int;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
warnIfMainThread("VODEventPager.nextPage");
|
||||
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
|
||||
pager.invokeV8<V8Value>("nextPage", ms);
|
||||
};
|
||||
if(newPager is V8ValueObject)
|
||||
pager = newPager;
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() = nextPage(0);
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
|
||||
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
|
||||
}
|
||||
}
|
|
@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
|||
final override val duration: Long;
|
||||
final override val viewCount: Long;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
final override val isLive: Boolean;
|
||||
final override val isShort: Boolean;
|
||||
|
||||
|
@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
|||
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
||||
isLive = _content.getOrThrow(config, "isLive", contextName);
|
||||
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
||||
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
|
||||
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
|
||||
if(playbackDateInt == null || playbackDateInt == 0.toLong())
|
||||
playbackDate = null;
|
||||
else
|
||||
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
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.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
|
@ -24,12 +25,17 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _plugin: JSClient;
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
private val _hasGetVODEvents: Boolean;
|
||||
|
||||
//Details
|
||||
override val description : String;
|
||||
|
@ -45,9 +51,9 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||
|
||||
override val subtitles: List<ISubtitleSource>;
|
||||
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
_plugin = plugin;
|
||||
val config = plugin.config;
|
||||
description = _content.getOrThrow(config, "description", contextName);
|
||||
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
|
||||
|
@ -69,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
_hasGetVODEvents = _content.has("getVODEvents");
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
|
@ -82,14 +89,16 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||
return getPlaybackTrackerJS();
|
||||
}
|
||||
private fun getPlaybackTrackerJS(): IPlaybackTracker? {
|
||||
return V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invoke<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_pluginConfig, tracker);
|
||||
else
|
||||
return@catchScriptErrors null;
|
||||
};
|
||||
return _plugin.busy {
|
||||
V8Plugin.catchScriptErrors(_pluginConfig, "VideoDetails", "videoDetails.getPlaybackTracker()") {
|
||||
val tracker = _content.invokeV8<V8Value>("getPlaybackTracker", arrayOf<Any>())
|
||||
?: return@catchScriptErrors null;
|
||||
if(tracker is V8ValueObject)
|
||||
return@catchScriptErrors JSPlaybackTracker(_plugin, tracker);
|
||||
else
|
||||
return@catchScriptErrors null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||
|
@ -106,8 +115,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||
return null;
|
||||
}
|
||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||
val contentPager = _content.invoke<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return JSContentPager(_pluginConfig, client, contentPager);
|
||||
return _plugin.busy {
|
||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||
}
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
|
@ -123,10 +134,23 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||
}
|
||||
|
||||
private fun getCommentsJS(client: JSClient): IPager<IPlatformComment>? {
|
||||
val commentPager = _content.invoke<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return null;
|
||||
return _plugin.busy {
|
||||
val commentPager = _content.invokeV8<V8Value>("getComments", arrayOf<Any>());
|
||||
if (commentPager !is V8ValueObject) //TODO: Maybe handle this better?
|
||||
return@busy null;
|
||||
|
||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
|
||||
fun hasVODEvents(): Boolean{
|
||||
return _hasGetVODEvents;
|
||||
}
|
||||
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
|
||||
if(!_hasGetVODEvents)
|
||||
return@busy null;
|
||||
|
||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
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.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSWeb : JSContent, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.WEB;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformWeb";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
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.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.WEB;
|
||||
|
||||
val html: String?;
|
||||
//TODO: Options?
|
||||
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformWeb";
|
||||
|
||||
html = obj.getOrDefault(client.config, "html", contextName, null);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
}
|
|
@ -6,6 +6,8 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
||||
override val licenseUri: String
|
||||
|
@ -25,7 +27,7 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
|||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models.sources
|
||||
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.V8Deferred
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
|
@ -13,8 +15,13 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
override val container : String;
|
||||
|
@ -50,6 +57,44 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
if(_plugin is DevJSClient)
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
}
|
||||
|
||||
return plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
|
||||
return@busy result.convert {
|
||||
it.value
|
||||
};
|
||||
}
|
||||
}
|
||||
override fun generate(): String? {
|
||||
if(!hasGenerate)
|
||||
return manifest;
|
||||
|
@ -62,21 +107,27 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||
if(_plugin is DevJSClient)
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashAudio.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
}
|
||||
|
||||
if(result != null){
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js.models.sources
|
|||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.V8Deferred
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
|
@ -15,11 +16,18 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
interface IJSDashManifestRawSource {
|
||||
val hasGenerate: Boolean;
|
||||
var manifest: String?;
|
||||
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
||||
fun generate(): String?;
|
||||
}
|
||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
|
@ -32,7 +40,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||
override val duration: Long;
|
||||
override val priority: Boolean;
|
||||
|
||||
var url: String?;
|
||||
val url: String?;
|
||||
override var manifest: String?;
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
|
@ -57,6 +65,45 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
if(_plugin is DevJSClient) {
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8Async<V8ValueString>("generate");
|
||||
}
|
||||
});
|
||||
|
||||
return plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
|
||||
return@busy result.convert {
|
||||
it.value
|
||||
};
|
||||
}
|
||||
}
|
||||
override open fun generate(): String? {
|
||||
if(!hasGenerate)
|
||||
return manifest;
|
||||
|
@ -67,22 +114,28 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||
if(_plugin is DevJSClient) {
|
||||
result = StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
|
||||
_plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
result = _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
|
||||
_obj.invokeString("generate");
|
||||
_plugin.isBusyWith("dashVideo.generate") {
|
||||
_obj.invokeV8<V8ValueString>("generate").value;
|
||||
}
|
||||
});
|
||||
|
||||
if(result != null){
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
_plugin.busy {
|
||||
val initStart = _obj.getOrDefault<Int>(_config, "initStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val initEnd = _obj.getOrDefault<Int>(_config, "initEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexStart = _obj.getOrDefault<Int>(_config, "indexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||
val indexEnd = _obj.getOrDefault<Int>(_config, "indexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
@ -110,6 +163,32 @@ class JSDashManifestMergingRawSource(
|
|||
override val priority: Boolean
|
||||
get() = video.priority;
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
val videoDashDef = video.generateAsync(scope);
|
||||
val audioDashDef = audio.generateAsync(scope);
|
||||
|
||||
return V8Deferred.merge(scope, listOf(videoDashDef, audioDashDef)) {
|
||||
val (videoDash: String?, audioDash: String?) = it;
|
||||
|
||||
if (videoDash != null && audioDash == null) return@merge videoDash;
|
||||
if (audioDash != null && videoDash == null) return@merge audioDash;
|
||||
if (videoDash == null) return@merge null;
|
||||
|
||||
//TODO: Temporary simple solution..make more reliable version
|
||||
|
||||
var result: String? = null;
|
||||
val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
|
||||
if (audioAdaptationSet != null) {
|
||||
result = videoDash.replace(
|
||||
"</AdaptationSet>",
|
||||
"</AdaptationSet>\n" + audioAdaptationSet.value
|
||||
)
|
||||
} else
|
||||
result = videoDash;
|
||||
|
||||
return@merge result;
|
||||
};
|
||||
}
|
||||
override fun generate(): String? {
|
||||
val videoDash = video.generate();
|
||||
val audioDash = audio.generate();
|
||||
|
|
|
@ -9,6 +9,8 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
|||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Void
|
||||
|
||||
class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
||||
IDashManifestWidevineSource, JSSource {
|
||||
|
@ -45,7 +47,7 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
|
|||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.orNull
|
||||
|
@ -38,7 +39,13 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||
|
||||
|
||||
companion object {
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource = JSHLSManifestAudioSource(plugin, obj);
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestAudioSource? {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orNull { fromV8HLS(plugin, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestAudioSource {
|
||||
obj.ensureIsBusy();
|
||||
return JSHLSManifestAudioSource(plugin, obj)
|
||||
};
|
||||
}
|
||||
}
|
|
@ -14,7 +14,9 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.orNull
|
||||
import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
|
||||
|
@ -53,36 +55,39 @@ abstract class JSSource {
|
|||
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
|
||||
}
|
||||
|
||||
fun getRequestModifier(): IRequestModifier? {
|
||||
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
|
||||
if(_requestModifier != null)
|
||||
return AdhocRequestModifier { url, headers ->
|
||||
return@isBusyWith AdhocRequestModifier { url, headers ->
|
||||
return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
|
||||
};
|
||||
|
||||
if (!hasRequestModifier || _obj.isClosed)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
|
||||
_obj.invoke("getRequestModifier", arrayOf<Any>());
|
||||
_obj.invokeV8("getRequestModifier", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
return JSRequestModifier(_plugin, result)
|
||||
return@isBusyWith JSRequestModifier(_plugin, result)
|
||||
}
|
||||
open fun getRequestExecutor(): JSRequestExecutor? {
|
||||
open fun getRequestExecutor(): JSRequestExecutor? = _plugin.isBusyWith("getRequestExecutor") {
|
||||
if (!hasRequestExecutor || _obj.isClosed)
|
||||
return null;
|
||||
return@isBusyWith null;
|
||||
|
||||
Logger.v("JSSource", "Request executor for [${type}] requesting");
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
|
||||
_obj.invoke("getRequestExecutor", arrayOf<Any>());
|
||||
_obj.invokeV8("getRequestExecutor", arrayOf<Any>());
|
||||
};
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
return null;
|
||||
Logger.v("JSSource", "Request executor for [${type}] received");
|
||||
|
||||
return JSRequestExecutor(_plugin, result)
|
||||
if (result !is V8ValueObject)
|
||||
return@isBusyWith null;
|
||||
|
||||
return@isBusyWith JSRequestExecutor(_plugin, result)
|
||||
}
|
||||
|
||||
fun getUnderlyingPlugin(): JSClient? {
|
||||
|
@ -105,8 +110,12 @@ abstract class JSSource {
|
|||
const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
|
||||
const val TYPE_VIDEOURL_WIDEVINE = "VideoUrlWidevineSource"
|
||||
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
|
||||
fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? {
|
||||
obj?.ensureIsBusy();
|
||||
return obj.orNull { fromV8Video(plugin, it as V8ValueObject) }
|
||||
};
|
||||
fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
|
||||
obj.ensureIsBusy()
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
|
||||
|
@ -123,13 +132,26 @@ abstract class JSSource {
|
|||
}
|
||||
}
|
||||
fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) };
|
||||
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj);
|
||||
fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj);
|
||||
fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj);
|
||||
fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource{
|
||||
obj.ensureIsBusy();
|
||||
return JSDashManifestSource(plugin, obj)
|
||||
};
|
||||
fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource{
|
||||
obj.ensureIsBusy()
|
||||
return JSDashManifestRawSource(plugin, obj);
|
||||
}
|
||||
fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource {
|
||||
obj?.ensureIsBusy();
|
||||
return JSDashManifestRawAudioSource(plugin, obj)
|
||||
};
|
||||
fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
|
||||
fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource {
|
||||
obj.ensureIsBusy();
|
||||
return JSHLSManifestSource(plugin, obj)
|
||||
};
|
||||
|
||||
fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? {
|
||||
obj.ensureIsBusy();
|
||||
val type = obj.getString("plugin_type");
|
||||
return when(type) {
|
||||
TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
|||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.ensureIsBusy
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
||||
|
@ -31,6 +32,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
|
|||
|
||||
|
||||
fun fromV8(plugin: JSClient, obj: V8ValueObject) : IVideoSourceDescriptor {
|
||||
obj.ensureIsBusy();
|
||||
val type = obj.getString("plugin_type")
|
||||
return when(type) {
|
||||
TYPE_MUXED -> JSVideoSourceDescriptor(plugin, obj);
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.platforms.js.JSClient
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
|
||||
class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
||||
override val licenseUri: String
|
||||
|
@ -25,7 +26,7 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
|||
return null
|
||||
|
||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||
_obj.invoke("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||
}
|
||||
|
||||
if (result !is V8ValueObject)
|
||||
|
|
|
@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
|||
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.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
|
@ -19,7 +18,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
|||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
|
|||
override val isLive: Boolean = false;
|
||||
override val isShort: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
constructor(file: File) {
|
||||
id = PlatformID("Local", file.path, "LOCAL")
|
||||
name = file.name;
|
||||
|
|
|
@ -7,12 +7,12 @@ import java.util.stream.IntStream
|
|||
* A Content MultiPager that returns results based on a specified distribution
|
||||
* TODO: Merge all basic distribution pagers
|
||||
*/
|
||||
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
|
||||
private val dist : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val dist : HashMap<IPager<T>, Float>;
|
||||
private val distConsumed : HashMap<IPager<T>, Float>;
|
||||
|
||||
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
|
||||
constructor(pagers : Map<IPager<T>, Float>) : super(pagers.keys.toMutableList()) {
|
||||
val distTotal = pagers.values.sum();
|
||||
dist = HashMap();
|
||||
|
||||
|
@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
||||
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
|
||||
if(options.size == 0)
|
||||
return -1;
|
||||
var bestIndex = 0;
|
||||
|
@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
|||
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -149,6 +149,7 @@ class AirPlayCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ abstract class CastingDevice {
|
|||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff = (System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0;
|
||||
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
|
|
|
@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
|||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
@ -33,7 +35,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
override var usedRemoteAddress: InetAddress? = null;
|
||||
override var localAddress: InetAddress? = null;
|
||||
override val canSetVolume: Boolean get() = true;
|
||||
override val canSetSpeed: Boolean get() = false; //TODO: Implement
|
||||
override val canSetSpeed: Boolean get() = true;
|
||||
|
||||
var addresses: Array<InetAddress>? = null;
|
||||
var port: Int = 0;
|
||||
|
@ -56,6 +58,11 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
private var _launchRetries = 0
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
private var _autoLaunchEnabled = true
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
|
@ -138,6 +145,23 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", json);
|
||||
}
|
||||
|
||||
override fun changeSpeed(speed: Double) {
|
||||
if (invokeInIOScopeIfRequired { changeSpeed(speed) }) return
|
||||
|
||||
val speedClamped = speed.coerceAtLeast(1.0).coerceAtLeast(1.0).coerceAtMost(2.0)
|
||||
setSpeed(speedClamped)
|
||||
val mediaSessionId = _mediaSessionId ?: return
|
||||
val transportId = _transportId ?: return
|
||||
val setSpeedObject = JSONObject().apply {
|
||||
put("type", "SET_PLAYBACK_RATE")
|
||||
put("mediaSessionId", mediaSessionId)
|
||||
put("playbackRate", speedClamped)
|
||||
put("requestId", _requestId++)
|
||||
}
|
||||
|
||||
sendChannelMessage(sourceId = "sender-0", destinationId = transportId, namespace = "urn:x-cast:com.google.cast.media", json = setSpeedObject.toString())
|
||||
}
|
||||
|
||||
override fun changeVolume(volume: Double) {
|
||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
||||
return;
|
||||
|
@ -229,6 +253,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
|
@ -268,6 +293,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
|
@ -280,8 +306,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
@ -322,6 +350,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -334,6 +363,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
//Connection loop
|
||||
while (_scopeIO?.isActive == true) {
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Connecting to Chromecast.");
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
|
||||
|
@ -392,7 +425,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
|
@ -404,7 +437,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
|
@ -413,15 +446,19 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
|
@ -485,6 +522,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to send channel message (sourceId: $sourceId, destinationId: $destinationId, namespace: $namespace, json: $json)", e);
|
||||
_socket?.close();
|
||||
Logger.i(TAG, "Socket disconnected.");
|
||||
|
||||
connectionState = CastConnectionState.CONNECTING;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -507,10 +548,12 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
|
@ -518,28 +561,48 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
playVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
_transportId = null
|
||||
|
||||
if (_launching) {
|
||||
Logger.i(TAG, "Player not found, launching.");
|
||||
launchPlayer();
|
||||
if (_autoLaunchEnabled) {
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
}
|
||||
} else {
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Player not found, disconnecting.");
|
||||
stop();
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false;
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
|
@ -566,7 +629,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
isPlaying = playerState == "PLAYING";
|
||||
if (isPlaying) {
|
||||
if (isPlaying || playerState == "PAUSED") {
|
||||
setTime(currentTime);
|
||||
}
|
||||
|
||||
|
@ -577,10 +640,18 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
stopCasting();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -615,6 +686,13 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.futo.platformplayer.casting
|
|||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
|
@ -24,6 +25,7 @@ import com.futo.platformplayer.toInetAddress
|
|||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -32,6 +34,7 @@ import java.io.IOException
|
|||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
@ -90,7 +93,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
private var _version: Long = 1;
|
||||
private var _thread: Thread? = null
|
||||
private var _pingThread: Thread? = null
|
||||
private var _lastPongTime = -1L
|
||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
||||
private var _outputStreamLock = Object()
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
|
@ -287,6 +290,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
break;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
||||
Thread.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -324,9 +328,9 @@ class FCastCastingDevice : CastingDevice {
|
|||
continue;
|
||||
}
|
||||
|
||||
localAddress = _socket?.localAddress;
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_lastPongTime = -1L
|
||||
localAddress = _socket?.localAddress
|
||||
_lastPongTime = System.currentTimeMillis()
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
|
||||
val buffer = ByteArray(4096);
|
||||
|
||||
|
@ -344,7 +348,7 @@ class FCastCastingDevice : CastingDevice {
|
|||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
|
@ -402,36 +406,32 @@ class FCastCastingDevice : CastingDevice {
|
|||
|
||||
_pingThread = Thread {
|
||||
Logger.i(TAG, "Started ping loop.")
|
||||
|
||||
while (_scopeIO?.isActive == true) {
|
||||
try {
|
||||
send(Opcode.Ping)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
send(Opcode.Ping)
|
||||
if (System.currentTimeMillis() - _lastPongTime > 15000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 15 seconds.")
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
Log.w(TAG, "Failed to send ping.")
|
||||
try {
|
||||
_socket?.close()
|
||||
_inputStream?.close()
|
||||
_outputStream?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*if (_lastPongTime != -1L && System.currentTimeMillis() - _lastPongTime > 6000) {
|
||||
Logger.w(TAG, "Closing socket due to last pong time being larger than 6 seconds.")
|
||||
|
||||
try {
|
||||
_socket?.close()
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to close socket.", e)
|
||||
}
|
||||
}*/
|
||||
|
||||
Thread.sleep(2000)
|
||||
Thread.sleep(5000)
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Stopped ping loop.");
|
||||
Logger.i(TAG, "Stopped ping loop.")
|
||||
}.apply { start() }
|
||||
} else {
|
||||
Log.i(TAG, "Thread was still alive, not restarted")
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.util.Xml
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Inet4Address
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
|
@ -33,13 +39,13 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
|
|||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.findPreferredAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.mdns.DnsService
|
||||
import com.futo.platformplayer.mdns.ServiceDiscoverer
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -53,11 +59,13 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class StateCasting {
|
||||
private val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||
|
@ -68,7 +76,6 @@ class StateCasting {
|
|||
private var _started = false;
|
||||
|
||||
var devices: HashMap<String, CastingDevice> = hashMapOf();
|
||||
var rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
val onDeviceAdded = Event1<CastingDevice>();
|
||||
val onDeviceChanged = Event1<CastingDevice>();
|
||||
val onDeviceRemoved = Event1<CastingDevice>();
|
||||
|
@ -82,48 +89,16 @@ class StateCasting {
|
|||
private var _audioExecutor: JSRequestExecutor? = null
|
||||
private val _client = ManagedHttpClient();
|
||||
var _resumeCastingDevice: CastingDeviceInfo? = null;
|
||||
val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
|
||||
"_googlecast._tcp.local",
|
||||
"_airplay._tcp.local",
|
||||
"_fastcast._tcp.local",
|
||||
"_fcast._tcp.local"
|
||||
)) { handleServiceUpdated(it) }
|
||||
|
||||
private var _nsdManager: NsdManager? = null
|
||||
val isCasting: Boolean get() = activeDevice != null;
|
||||
private val _castId = AtomicInteger(0)
|
||||
|
||||
private fun handleServiceUpdated(services: List<DnsService>) {
|
||||
for (s in services) {
|
||||
//TODO: Addresses IPv4 only?
|
||||
val addresses = s.addresses.toTypedArray()
|
||||
val port = s.port.toInt()
|
||||
var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
|
||||
if (s.name.endsWith("._googlecast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateChromeCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._airplay._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateAirPlayDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fastcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
} else if (s.name.endsWith("._fcast._tcp.local")) {
|
||||
if (name == null) {
|
||||
name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
|
||||
}
|
||||
|
||||
addOrUpdateFastCastDevice(name, addresses, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
fun handleUrl(context: Context, url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
|
@ -188,30 +163,34 @@ class StateCasting {
|
|||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
rememberedDevices.clear();
|
||||
rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
startDiscovering()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.start()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
|
||||
private fun startDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopDiscovering() {
|
||||
try {
|
||||
_serviceDiscoverer.stop()
|
||||
} catch (e: Throwable) {
|
||||
Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
|
||||
private fun stopDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -237,8 +216,90 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(service, { it.run() }, object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, serviceInfo.hostAddresses.toTypedArray(), serviceInfo.port)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(serviceInfo.serviceName, arrayOf(serviceInfo.host), serviceInfo.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _castingDialogLock = Any();
|
||||
private var _currentDialog: AlertDialog? = null;
|
||||
|
||||
@Synchronized
|
||||
fun connectDevice(device: CastingDevice) {
|
||||
if (activeDevice == device)
|
||||
|
@ -272,10 +333,41 @@ class StateCasting {
|
|||
invokeInMainScopeIfRequired {
|
||||
StateApp.withContext(false) { context ->
|
||||
context.let {
|
||||
Logger.i(TAG, "Casting state changed to ${castConnectionState}");
|
||||
when (castConnectionState) {
|
||||
CastConnectionState.CONNECTED -> UIDialogs.toast(it, "Connected to device")
|
||||
CastConnectionState.CONNECTING -> UIDialogs.toast(it, "Connecting to device...")
|
||||
CastConnectionState.DISCONNECTED -> UIDialogs.toast(it, "Disconnected from device")
|
||||
CastConnectionState.CONNECTED -> {
|
||||
Logger.i(TAG, "Casting connected to [${device.name}]");
|
||||
UIDialogs.appToast("Connected to device")
|
||||
synchronized(_castingDialogLock) {
|
||||
if(_currentDialog != null) {
|
||||
_currentDialog?.hide();
|
||||
_currentDialog = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
CastConnectionState.CONNECTING -> {
|
||||
Logger.i(TAG, "Casting connecting to [${device.name}]");
|
||||
UIDialogs.toast(it, "Connecting to device...")
|
||||
synchronized(_castingDialogLock) {
|
||||
if(_currentDialog == null) {
|
||||
_currentDialog = UIDialogs.showDialog(context, R.drawable.ic_loader_animated, true,
|
||||
"Connecting to [${device.name}]",
|
||||
"Make sure you are on the same network\n\nVPNs and guest networks can cause issues", null, -2,
|
||||
UIDialogs.Action("Disconnect", {
|
||||
device.stop();
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
UIDialogs.toast(it, "Disconnected from device")
|
||||
synchronized(_castingDialogLock) {
|
||||
if(_currentDialog != null) {
|
||||
_currentDialog?.hide();
|
||||
_currentDialog = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -295,9 +387,6 @@ class StateCasting {
|
|||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||
};
|
||||
|
||||
addRememberedDevice(device);
|
||||
Logger.i(TAG, "Device added to active discovery. Active discovery now contains ${_storage.getDevicesCount()} devices.")
|
||||
|
||||
try {
|
||||
device.start();
|
||||
} catch (e: Throwable) {
|
||||
|
@ -319,21 +408,22 @@ class StateCasting {
|
|||
return addRememberedDevice(device);
|
||||
}
|
||||
|
||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||
return _storage.getDevices().map { deviceFromCastingDeviceInfo(it) }
|
||||
}
|
||||
|
||||
fun getRememberedCastingDeviceNames(): List<String> {
|
||||
return _storage.getDeviceNames()
|
||||
}
|
||||
|
||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||
val deviceInfo = device.getDeviceInfo()
|
||||
val foundInfo = _storage.addDevice(deviceInfo)
|
||||
if (foundInfo == deviceInfo) {
|
||||
rememberedDevices.add(device);
|
||||
return foundInfo;
|
||||
}
|
||||
|
||||
return foundInfo;
|
||||
return _storage.addDevice(deviceInfo)
|
||||
}
|
||||
|
||||
fun removeRememberedDevice(device: CastingDevice) {
|
||||
val name = device.name ?: return;
|
||||
_storage.removeDevice(name);
|
||||
rememberedDevices.remove(device);
|
||||
val name = device.name ?: return
|
||||
_storage.removeDevice(name)
|
||||
}
|
||||
|
||||
private fun invokeInMainScopeIfRequired(action: () -> Unit){
|
||||
|
@ -345,129 +435,112 @@ class StateCasting {
|
|||
action();
|
||||
}
|
||||
|
||||
fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean {
|
||||
val ad = activeDevice ?: return false;
|
||||
if (ad.connectionState != CastConnectionState.CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
fun cancel() {
|
||||
_castId.incrementAndGet()
|
||||
}
|
||||
|
||||
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
|
||||
suspend fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val ad = activeDevice ?: return@withContext false;
|
||||
if (ad.connectionState != CastConnectionState.CONNECTED) {
|
||||
return@withContext false;
|
||||
}
|
||||
|
||||
var sourceCount = 0;
|
||||
if (videoSource != null) sourceCount++;
|
||||
if (audioSource != null) sourceCount++;
|
||||
if (subtitleSource != null) sourceCount++;
|
||||
val resumePosition = if (ms > 0L) (ms.toDouble() / 1000.0) else 0.0;
|
||||
val castId = _castId.incrementAndGet()
|
||||
|
||||
if (sourceCount < 1) {
|
||||
throw Exception("At least one source should be specified.");
|
||||
}
|
||||
var sourceCount = 0;
|
||||
if (videoSource != null) sourceCount++;
|
||||
if (audioSource != null) sourceCount++;
|
||||
if (subtitleSource != null) sourceCount++;
|
||||
|
||||
if (sourceCount > 1) {
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
if (sourceCount < 1) {
|
||||
throw Exception("At least one source should be specified.");
|
||||
}
|
||||
|
||||
if (sourceCount > 1) {
|
||||
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
|
||||
if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as local HLS");
|
||||
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
}
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as local DASH");
|
||||
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed);
|
||||
}
|
||||
} else {
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||
if (isRawDash) {
|
||||
Logger.i(TAG, "Casting as raw DASH");
|
||||
val isRawDash = videoSource is JSDashManifestRawSource || audioSource is JSDashManifestRawAudioSource
|
||||
if (isRawDash) {
|
||||
Logger.i(TAG, "Casting as raw DASH");
|
||||
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
}
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, audioSource as JSDashManifestRawAudioSource?, subtitleSource, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else {
|
||||
if (ad is FCastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as DASH direct");
|
||||
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else if (ad is AirPlayCastingDevice) {
|
||||
Logger.i(TAG, "Casting as HLS indirect");
|
||||
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
}
|
||||
Logger.i(TAG, "Casting as DASH indirect");
|
||||
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed);
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw videoSource=${videoSource}.", e);
|
||||
}
|
||||
}
|
||||
} else if (audioSource is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to start casting DASH raw audioSource=${audioSource}.", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
|
||||
).filterNotNull().joinToString(", ");
|
||||
throw UnsupportedCastException(str);
|
||||
}
|
||||
}
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
return true;
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
||||
Logger.i(TAG, "Casting as singular video");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if (audioSource is IAudioUrlSource) {
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
||||
Logger.i(TAG, "Casting as singular audio");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed);
|
||||
} else if(videoSource is IHLSManifestSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied HLS");
|
||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if(audioSource is IHLSManifestAudioSource) {
|
||||
if (proxyStreams || ad is ChromecastCastingDevice) {
|
||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
||||
} else {
|
||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed);
|
||||
}
|
||||
} else if (videoSource is LocalVideoSource) {
|
||||
Logger.i(TAG, "Casting as local video");
|
||||
castLocalVideo(video, videoSource, resumePosition, speed);
|
||||
} else if (audioSource is LocalAudioSource) {
|
||||
Logger.i(TAG, "Casting as local audio");
|
||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||
} else if (videoSource is JSDashManifestRawSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else if (audioSource is JSDashManifestRawAudioSource) {
|
||||
Logger.i(TAG, "Casting as JSDashManifestRawSource audio");
|
||||
castDashRaw(contentResolver, video, null, audioSource as JSDashManifestRawAudioSource?, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||
} else {
|
||||
var str = listOf(
|
||||
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
|
||||
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
|
||||
if(subtitleSource != null) "Subtitles: ${subtitleSource::class.java.simpleName}" else null
|
||||
).filterNotNull().joinToString(", ");
|
||||
throw UnsupportedCastException(str);
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext true;
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeVideo(): Boolean {
|
||||
|
@ -497,7 +570,7 @@ class StateCasting {
|
|||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
@ -516,7 +589,7 @@ class StateCasting {
|
|||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
|
@ -535,7 +608,7 @@ class StateCasting {
|
|||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val url = getLocalUrl(ad)
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -631,7 +704,7 @@ class StateCasting {
|
|||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -679,9 +752,9 @@ class StateCasting {
|
|||
|
||||
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val videoPath = "/video-${id}"
|
||||
|
@ -746,7 +819,7 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -916,7 +989,7 @@ class StateCasting {
|
|||
|
||||
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -1042,11 +1115,16 @@ class StateCasting {
|
|||
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
|
||||
}
|
||||
|
||||
private fun shouldProxyStreams(castingDevice: CastingDevice, videoSource: IVideoSource?, audioSource: IAudioSource?): Boolean {
|
||||
val hasRequestModifier = (videoSource as? JSSource)?.hasRequestModifier == true || (audioSource as? JSSource)?.hasRequestModifier == true
|
||||
return Settings.instance.casting.alwaysProxyRequests || castingDevice !is FCastCastingDevice || hasRequestModifier
|
||||
}
|
||||
|
||||
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
|
||||
val proxyStreams = shouldProxyStreams(ad, videoSource, audioSource)
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -1132,14 +1210,30 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getLocalUrl(ad: CastingDevice): String {
|
||||
var address = ad.localAddress!!
|
||||
if (Settings.instance.casting.allowLinkLocalIpv4) {
|
||||
if (address.isLinkLocalAddress && address is Inet6Address) {
|
||||
address = findPreferredAddress() ?: address
|
||||
Logger.i(TAG, "Selected casting address: $address")
|
||||
}
|
||||
} else {
|
||||
if (address.isLinkLocalAddress) {
|
||||
address = findPreferredAddress() ?: address
|
||||
Logger.i(TAG, "Selected casting address: $address")
|
||||
}
|
||||
}
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?, castId: Int, onLoadingEstimate: ((Int) -> Unit)? = null, onLoading: ((Boolean) -> Unit)? = null) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -1180,20 +1274,48 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
|
||||
var dashContent = withContext(Dispatchers.IO) {
|
||||
var dashContent: String = withContext(Dispatchers.IO) {
|
||||
stopVideo()
|
||||
|
||||
//TODO: Include subtitlesURl in the future
|
||||
return@withContext if (audioSource != null && videoSource != null) {
|
||||
JSDashManifestMergingRawSource(videoSource, audioSource).generate()
|
||||
val deferred = if (audioSource != null && videoSource != null) {
|
||||
JSDashManifestMergingRawSource(videoSource, audioSource).generateAsync(_scopeIO)
|
||||
} else if (audioSource != null) {
|
||||
audioSource.generate()
|
||||
audioSource.generateAsync(_scopeIO)
|
||||
} else if (videoSource != null) {
|
||||
videoSource.generate()
|
||||
videoSource.generateAsync(_scopeIO)
|
||||
} else {
|
||||
Logger.e(TAG, "Expected at least audio or video to be set")
|
||||
null
|
||||
}
|
||||
|
||||
if (deferred != null) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (deferred.estDuration >= 0) {
|
||||
onLoadingEstimate?.invoke(deferred.estDuration)
|
||||
} else {
|
||||
onLoading?.invoke(true)
|
||||
}
|
||||
}
|
||||
deferred.await()
|
||||
} finally {
|
||||
if (castId == _castId.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoading?.invoke(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return@withContext null
|
||||
}
|
||||
} ?: throw Exception("Dash is null")
|
||||
|
||||
if (castId != _castId.get()) {
|
||||
Log.i(TAG, "Get DASH cancelled.")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
for (representation in representationRegex.findAll(dashContent)) {
|
||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||
|
|
|
@ -82,7 +82,11 @@ class TaskHandler<TParameter, TResult> {
|
|||
handled = true;
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Handled exception in TaskHandler onSuccess.", e);
|
||||
onError.emit(e, parameter);
|
||||
try {
|
||||
onError.emit(e, parameter);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in .exception handler 1", e)
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
@ -99,10 +103,14 @@ class TaskHandler<TParameter, TResult> {
|
|||
if (id != _idGenerator)
|
||||
return@withContext;
|
||||
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
try {
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in .exception handler 2", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,10 +47,10 @@ class DeveloperEndpoints(private val context: Context) {
|
|||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
|
||||
val remotes = mutableListOf<V8RemoteObject>();
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
|
||||
val remotes = mutableListOf<V8RemoteObject?>();
|
||||
for(obj in objs)
|
||||
remotes.add(createRemoteObject(obj)!!);
|
||||
remotes.add(createRemoteObject(obj));
|
||||
return remotes;
|
||||
}
|
||||
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
||||
|
|
|
@ -106,7 +106,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||
};
|
||||
|
||||
_buttonTutorial.setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(context)
|
||||
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
@ -130,7 +130,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
|||
|
||||
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
||||
if (shouldShowCastingDialog) {
|
||||
UIDialogs.showCastingDialog(context);
|
||||
UIDialogs.showCastingDialog(context, ownerActivity);
|
||||
}
|
||||
|
||||
dismiss();
|
||||
|
|
|
@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
|||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
dismiss()
|
||||
UIDialogs.showCastingAddDialog(context)
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@ import android.view.View
|
|||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -21,22 +23,21 @@ import com.futo.platformplayer.casting.StateCasting
|
|||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapter
|
||||
import com.futo.platformplayer.views.adapters.DeviceAdapterEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private lateinit var _imageLoader: ImageView;
|
||||
private lateinit var _buttonClose: Button;
|
||||
private lateinit var _buttonAdd: ImageButton;
|
||||
private lateinit var _buttonScanQR: ImageButton;
|
||||
private lateinit var _buttonAdd: LinearLayout;
|
||||
private lateinit var _buttonScanQR: LinearLayout;
|
||||
private lateinit var _textNoDevicesFound: TextView;
|
||||
private lateinit var _textNoDevicesRemembered: TextView;
|
||||
private lateinit var _recyclerDevices: RecyclerView;
|
||||
private lateinit var _recyclerRememberedDevices: RecyclerView;
|
||||
private lateinit var _adapter: DeviceAdapter;
|
||||
private lateinit var _rememberedAdapter: DeviceAdapter;
|
||||
private val _devices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _rememberedDevices: ArrayList<CastingDevice> = arrayListOf();
|
||||
private val _devices: MutableSet<String> = mutableSetOf()
|
||||
private val _rememberedDevices: MutableSet<String> = mutableSetOf()
|
||||
private val _unifiedDevices: MutableList<DeviceAdapterEntry> = mutableListOf()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
@ -45,46 +46,44 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
_imageLoader = findViewById(R.id.image_loader);
|
||||
_buttonClose = findViewById(R.id.button_close);
|
||||
_buttonAdd = findViewById(R.id.button_add);
|
||||
_buttonScanQR = findViewById(R.id.button_scan_qr);
|
||||
_buttonScanQR = findViewById(R.id.button_qr);
|
||||
_recyclerDevices = findViewById(R.id.recycler_devices);
|
||||
_recyclerRememberedDevices = findViewById(R.id.recycler_remembered_devices);
|
||||
_textNoDevicesFound = findViewById(R.id.text_no_devices_found);
|
||||
_textNoDevicesRemembered = findViewById(R.id.text_no_devices_remembered);
|
||||
|
||||
_adapter = DeviceAdapter(_devices, false);
|
||||
_adapter = DeviceAdapter(_unifiedDevices)
|
||||
_recyclerDevices.adapter = _adapter;
|
||||
_recyclerDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_rememberedAdapter = DeviceAdapter(_rememberedDevices, true);
|
||||
_rememberedAdapter.onRemove.subscribe { d ->
|
||||
if (StateCasting.instance.activeDevice == d) {
|
||||
d.stopCasting();
|
||||
_adapter.onPin.subscribe { d ->
|
||||
val isRemembered = _rememberedDevices.contains(d.name)
|
||||
val newIsRemembered = !isRemembered
|
||||
if (newIsRemembered) {
|
||||
StateCasting.instance.addRememberedDevice(d)
|
||||
val name = d.name
|
||||
if (name != null) {
|
||||
_rememberedDevices.add(name)
|
||||
}
|
||||
} else {
|
||||
StateCasting.instance.removeRememberedDevice(d)
|
||||
_rememberedDevices.remove(d.name)
|
||||
}
|
||||
|
||||
StateCasting.instance.removeRememberedDevice(d);
|
||||
val index = _rememberedDevices.indexOf(d);
|
||||
if (index != -1) {
|
||||
_rememberedDevices.removeAt(index);
|
||||
_rememberedAdapter.notifyItemRemoved(index);
|
||||
}
|
||||
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
_rememberedAdapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
UIDialogs.showCastingDialog(context)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
//TODO: Integrate remembered into the main list
|
||||
//TODO: Add green indicator to indicate a device is oneline
|
||||
//TODO: Add pinning
|
||||
//TODO: Implement QR code as an option in add manually
|
||||
//TODO: Remove start button
|
||||
|
||||
_adapter.onConnect.subscribe { _ ->
|
||||
dismiss()
|
||||
UIDialogs.showCastingDialog(context)
|
||||
//UIDialogs.showCastingDialog(context)
|
||||
}
|
||||
_recyclerRememberedDevices.adapter = _rememberedAdapter;
|
||||
_recyclerRememberedDevices.layoutManager = LinearLayoutManager(context);
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
UIDialogs.showCastingAddDialog(context);
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
|
@ -104,78 +103,108 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
|||
super.show();
|
||||
Logger.i(TAG, "Dialog shown.");
|
||||
|
||||
StateCasting.instance.startDiscovering()
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.start();
|
||||
|
||||
_devices.clear();
|
||||
synchronized (StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values);
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
|
||||
_rememberedDevices.clear();
|
||||
synchronized (StateCasting.instance.rememberedDevices) {
|
||||
_rememberedDevices.addAll(StateCasting.instance.rememberedDevices);
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
_textNoDevicesRemembered.visibility = if (_rememberedDevices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerRememberedDevices.visibility = if (_rememberedDevices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
_devices.add(d);
|
||||
_adapter.notifyItemInserted(_devices.size - 1);
|
||||
_textNoDevicesFound.visibility = View.GONE;
|
||||
_recyclerDevices.visibility = View.VISIBLE;
|
||||
};
|
||||
val name = d.name
|
||||
if (name != null)
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
val index = _unifiedDevices.indexOfFirst { it.castingDevice.name == d.name }
|
||||
if (index != -1) {
|
||||
_unifiedDevices[index] = DeviceAdapterEntry(d, _unifiedDevices[index].isPinnedDevice, _unifiedDevices[index].isOnlineDevice)
|
||||
_adapter.notifyItemChanged(index)
|
||||
}
|
||||
|
||||
_devices[index] = d;
|
||||
_adapter.notifyItemChanged(index);
|
||||
};
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceRemoved.subscribe(this) { d ->
|
||||
val index = _devices.indexOf(d);
|
||||
if (index == -1) {
|
||||
return@subscribe;
|
||||
}
|
||||
|
||||
_devices.removeAt(index);
|
||||
_adapter.notifyItemRemoved(index);
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
};
|
||||
_devices.remove(d.name)
|
||||
updateUnifiedList()
|
||||
}
|
||||
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.subscribe(this) { _, connectionState ->
|
||||
if (connectionState != CastConnectionState.CONNECTED) {
|
||||
return@subscribe;
|
||||
if (connectionState == CastConnectionState.CONNECTED) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
dismiss();
|
||||
};
|
||||
};
|
||||
|
||||
_adapter.notifyDataSetChanged();
|
||||
_rememberedAdapter.notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss();
|
||||
super.dismiss()
|
||||
(_imageLoader.drawable as Animatable?)?.stop()
|
||||
StateCasting.instance.onDeviceAdded.remove(this)
|
||||
StateCasting.instance.onDeviceChanged.remove(this)
|
||||
StateCasting.instance.onDeviceRemoved.remove(this)
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this)
|
||||
}
|
||||
|
||||
(_imageLoader.drawable as Animatable?)?.stop();
|
||||
private fun updateUnifiedList() {
|
||||
val oldList = ArrayList(_unifiedDevices)
|
||||
val newList = buildUnifiedList()
|
||||
|
||||
StateCasting.instance.stopDiscovering()
|
||||
StateCasting.instance.onDeviceAdded.remove(this);
|
||||
StateCasting.instance.onDeviceChanged.remove(this);
|
||||
StateCasting.instance.onDeviceRemoved.remove(this);
|
||||
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
|
||||
val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = oldList.size
|
||||
override fun getNewListSize() = newList.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||
val oldItem = oldList[oldItemPosition]
|
||||
val newItem = newList[newItemPosition]
|
||||
return oldItem.castingDevice.name == newItem.castingDevice.name
|
||||
&& oldItem.castingDevice.isReady == newItem.castingDevice.isReady
|
||||
&& oldItem.isOnlineDevice == newItem.isOnlineDevice
|
||||
&& oldItem.isPinnedDevice == newItem.isPinnedDevice
|
||||
}
|
||||
})
|
||||
|
||||
_unifiedDevices.clear()
|
||||
_unifiedDevices.addAll(newList)
|
||||
diffResult.dispatchUpdatesTo(_adapter)
|
||||
|
||||
_textNoDevicesFound.visibility = if (_unifiedDevices.isEmpty()) View.VISIBLE else View.GONE
|
||||
_recyclerDevices.visibility = if (_unifiedDevices.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun buildUnifiedList(): List<DeviceAdapterEntry> {
|
||||
val onlineDevices = StateCasting.instance.devices.values.associateBy { it.name }
|
||||
val rememberedDevices = StateCasting.instance.getRememberedCastingDevices().associateBy { it.name }
|
||||
|
||||
val unifiedList = mutableListOf<DeviceAdapterEntry>()
|
||||
|
||||
val intersectionNames = _devices.intersect(_rememberedDevices)
|
||||
for (name in intersectionNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, true)) }
|
||||
}
|
||||
|
||||
val onlineOnlyNames = _devices - _rememberedDevices
|
||||
for (name in onlineOnlyNames) {
|
||||
onlineDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, false, true)) }
|
||||
}
|
||||
|
||||
val rememberedOnlyNames = _rememberedDevices - _devices
|
||||
for (name in rememberedOnlyNames) {
|
||||
rememberedDevices[name]?.let { unifiedList.add(DeviceAdapterEntry(it, true, false)) }
|
||||
}
|
||||
|
||||
return unifiedList
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
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