mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-29 19:13:01 +02:00
Compare commits
288 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fc549afb | |||
| b345ba5ca3 | |||
| c65cee86b1 | |||
| cf3fc61f6a | |||
| d03019f0b7 | |||
| f1ce0078fd | |||
| 32de3649ef | |||
| 1a301236da | |||
| c695379885 | |||
| 73466892f7 | |||
| bb8a9d4dd7 | |||
| 43ed2b16ab | |||
| 64938dba6c | |||
| 8b7d51cd70 | |||
| ace7ca1551 | |||
| 22b5adc4b8 | |||
| 0f7fb9059b | |||
| 05afa12274 | |||
| b4a280cee8 | |||
| ac5d7eab2a | |||
| b624d45ab6 | |||
| 5340088ada | |||
| fcab0f5ee5 | |||
| 80c9b27d48 | |||
| f54216d52f | |||
| fea69d265a | |||
| 030086e769 | |||
| 81516c31fb | |||
| 3d13a21700 | |||
| cd90497a59 | |||
| c14d2580ee | |||
| 795259564d | |||
| 81d0b08306 | |||
| 9a97a901fb | |||
| d9b23eff62 | |||
| 8591deaf86 | |||
| 22c5581d00 | |||
| 6e815dc868 | |||
| 1ac409561c | |||
| 897ba8a560 | |||
| 8982ea2289 | |||
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 837ee76bdc | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| 624ef3c6e9 | |||
| 3d5b9a94fb | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 | |||
| 34d2e62314 | |||
| 1075ded170 | |||
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| bda534e485 | |||
| 09fd4c0881 | |||
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| def39ba397 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 894e400819 | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 5b40da109b | |||
| 949294952f | |||
| 40a058e369 | |||
| a070d78dd9 | |||
| 105ac538bb | |||
| ce2029774e | |||
| 50c63d7e8d | |||
| d3534080d7 | |||
| b5025193a5 | |||
| 3f85b7ed78 | |||
| 98d008ef6c | |||
| 20eb53fc38 | |||
| 1ea7b307fa | |||
| f18571e0b2 | |||
| 70872d429a | |||
| cbf3db6e30 | |||
| 0be0dcfadc | |||
| abd226c33d | |||
| 89dbdc99a0 | |||
| f89ed18a49 | |||
| 77ac2b537c | |||
| 8ab03b6b66 | |||
| dad70e57c6 | |||
| eb9c6c8330 | |||
| 68da797f4d | |||
| 25948dd296 | |||
| 10d39d6ed1 | |||
| 85e8e674dd | |||
| 0d70392bf0 | |||
| f89b074d28 | |||
| ee2af411aa | |||
| 9ffbe6dd03 | |||
| 0ae6ac2fac | |||
| fd835cc54e | |||
| 07f3140038 | |||
| 3e753d70de | |||
| d578c47975 | |||
| b7a61425ca | |||
| 727f977672 | |||
| fc9d5eeb27 | |||
| f17e147b4e | |||
| 1c569b465b | |||
| 6289c85bd5 | |||
| 098599853b | |||
| 68d11f6d58 | |||
| 74f6b9aa62 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| 4433364cd8 | |||
| 2c957d7188 | |||
| f229f4ed1f | |||
| e8d1f73e29 | |||
| dd2cf18cb2 | |||
| 5355602577 | |||
| 8cc82e4d16 | |||
| d6468ba283 | |||
| 4b5ed38175 | |||
| 75eb7359de | |||
| fd519d48cf | |||
| 6f1866ac27 | |||
| 0dc0f07785 | |||
| bae8cb7bc4 | |||
| d5a696289b | |||
| 75ef7085eb | |||
| 347ef855b3 | |||
| aac19aef86 | |||
| 33efc5c21d | |||
| fc7001c295 | |||
| 9b68394f70 | |||
| e2ef8c2593 | |||
| 551bfe44ac | |||
| 6fbfa98ad3 | |||
| 9b97e05e3b | |||
| 62a2f42d68 | |||
| da44e86163 | |||
| 682b86330e | |||
| c9ba8a09e2 | |||
| 7d19c2357c | |||
| 64030a038c | |||
| 87d93c2ed8 | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| bce93b8e0f | |||
| 9a950958f9 | |||
| b6676e7763 | |||
| 35fe093e5c | |||
| 7cad4fbe07 | |||
| 240772790d | |||
| d659ecc518 | |||
| 7d8bb20b71 | |||
| 1cf5f776d5 | |||
| 137ba85538 | |||
| 642d218c54 | |||
| 26b5470200 | |||
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 | |||
| 7c70e58129 | |||
| 09bc180d4f | |||
| 76a42f5f6f |
@@ -1,2 +1,6 @@
|
|||||||
aar/* filter=lfs diff=lfs merge=lfs -text
|
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
+43
-16
@@ -1,37 +1,64 @@
|
|||||||
variables:
|
variables:
|
||||||
GIT_SUBMODULE_STRATEGY: recursive
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
stages:
|
|
||||||
- buildAndDeployApkUnstable
|
|
||||||
- buildAndDeployApkStable
|
|
||||||
- buildAndDeployPlaystore
|
|
||||||
|
|
||||||
buildAndDeployApkUnstable:
|
buildAndDeployApkUnstable:
|
||||||
stage: buildAndDeployApkUnstable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-unstable.sh
|
- sh deploy-unstable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- ^(dev)
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
allow_failure: true
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/unstable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployApkStable:
|
buildAndDeployApkStable:
|
||||||
stage: buildAndDeployApkStable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-stable.sh
|
- sh deploy-stable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- branches
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/stable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployPlaystore:
|
buildAndDeployPlaystore:
|
||||||
stage: buildAndDeployPlaystore
|
stage: deploy
|
||||||
script:
|
script:
|
||||||
- sh deploy-playstore.sh
|
- sh build-playstore.sh
|
||||||
|
- bash venv-playstore.sh
|
||||||
|
- . .venv-playstore/bin/activate
|
||||||
|
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
when: on_success
|
||||||
- branches
|
needs:
|
||||||
when: manual
|
- buildAndDeployApkStable
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/bundle/playstoreRelease/*.aab
|
||||||
|
|
||||||
|
updateFdroidRepo:
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
when: on_success
|
||||||
|
needs:
|
||||||
|
- job: buildAndDeployApkStable
|
||||||
|
artifacts: true
|
||||||
|
before_script:
|
||||||
|
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
|
||||||
|
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
|
||||||
|
script:
|
||||||
|
- python3 update_fdroid_index.py
|
||||||
|
|||||||
+18
-6
@@ -64,12 +64,6 @@
|
|||||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||||
path = app/src/stable/assets/sources/bilibili
|
path = app/src/stable/assets/sources/bilibili
|
||||||
url = ../plugins/bilibili.git
|
url = ../plugins/bilibili.git
|
||||||
[submodule "app/src/stable/assets/sources/spotify"]
|
|
||||||
path = app/src/stable/assets/sources/spotify
|
|
||||||
url = ../plugins/spotify.git
|
|
||||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
|
||||||
path = app/src/unstable/assets/sources/spotify
|
|
||||||
url = ../plugins/spotify.git
|
|
||||||
[submodule "app/src/stable/assets/sources/bitchute"]
|
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||||
path = app/src/stable/assets/sources/bitchute
|
path = app/src/stable/assets/sources/bitchute
|
||||||
url = ../plugins/bitchute.git
|
url = ../plugins/bitchute.git
|
||||||
@@ -112,3 +106,21 @@
|
|||||||
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||||
path = app/src/unstable/assets/sources/mixcloud
|
path = app/src/unstable/assets/sources/mixcloud
|
||||||
url = ../plugins/mixcloud.git
|
url = ../plugins/mixcloud.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/radiobrowser"]
|
||||||
|
path = app/src/unstable/assets/sources/radiobrowser
|
||||||
|
url = ../plugins/radiobrowser.git
|
||||||
|
[submodule "app/src/stable/assets/sources/radiobrowser"]
|
||||||
|
path = app/src/stable/assets/sources/radiobrowser
|
||||||
|
url = ../plugins/radiobrowser.git
|
||||||
|
[submodule "app/src/stable/assets/sources/redbull-tv"]
|
||||||
|
path = app/src/stable/assets/sources/redbull-tv
|
||||||
|
url = ../plugins/redbull-tv.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/redbull-tv"]
|
||||||
|
path = app/src/unstable/assets/sources/redbull-tv
|
||||||
|
url = ../plugins/redbull-tv.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/fosdem"]
|
||||||
|
path = app/src/unstable/assets/sources/fosdem
|
||||||
|
url = ../plugins/fosdem.git
|
||||||
|
[submodule "app/src/stable/assets/sources/fosdem"]
|
||||||
|
path = app/src/stable/assets/sources/fosdem
|
||||||
|
url = ../plugins/fosdem.git
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:ea10d3c5562c9f449a4e89e9c3dfcf881ed79a952f3409bc005bcc62c2cf4b81
|
|
||||||
size 65512557
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||||
|
size 36133152
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
|
||||||
|
size 6342128
|
||||||
+45
-51
@@ -1,8 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
|
||||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
id 'org.ajoberstar.grgit' version '5.3.3'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
@@ -39,7 +39,7 @@ protobuf {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.futo.platformplayer'
|
namespace 'com.futo.platformplayer'
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
flavorDimensions "buildType"
|
flavorDimensions "buildType"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
stable {
|
stable {
|
||||||
@@ -97,7 +97,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 28
|
minSdk 28
|
||||||
targetSdk 34
|
targetSdk 36
|
||||||
versionCode gitVersionCode
|
versionCode gitVersionCode
|
||||||
versionName gitVersionName
|
versionName gitVersionName
|
||||||
|
|
||||||
@@ -146,6 +146,7 @@ android {
|
|||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main {
|
main {
|
||||||
|
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||||
assets {
|
assets {
|
||||||
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||||
}
|
}
|
||||||
@@ -155,86 +156,79 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
//implementation 'com.google.dagger:dagger:2.48'
|
//implementation 'com.google.dagger:dagger:2.48'
|
||||||
implementation 'androidx.test:monitor:1.7.2'
|
implementation 'androidx.test:monitor:1.8.0'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.13.0'
|
||||||
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||||
|
|
||||||
//Core
|
//Core
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.17.0'
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||||
implementation 'com.google.android.material:material:1.11.0'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.documentfile:documentfile:1.1.0'
|
||||||
|
|
||||||
//Images
|
//Images
|
||||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
implementation 'com.github.bumptech.glide:glide:5.0.5'
|
||||||
|
|
||||||
//Async
|
//Async
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
||||||
|
|
||||||
//HTTP
|
//HTTP
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||||
|
|
||||||
//JSON
|
//JSON
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
|
||||||
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||||
|
|
||||||
//JS
|
//JS
|
||||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
|
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
|
||||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
implementation 'androidx.media3:media3-ui:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
|
||||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
implementation 'androidx.media3:media3-transformer:1.9.0'
|
||||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||||
implementation 'androidx.media:media:1.7.0'
|
implementation 'androidx.media:media:1.7.1'
|
||||||
|
|
||||||
//Other
|
//Other
|
||||||
implementation 'org.jsoup:jsoup:1.15.3'
|
implementation 'org.jsoup:jsoup:1.21.2'
|
||||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||||
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
|
||||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||||
implementation 'com.google.zxing:core:3.4.1'
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation 'androidx.webkit:webkit:1.15.0'
|
||||||
|
|
||||||
//Protobuf
|
//Protobuf
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
|
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
||||||
|
|
||||||
implementation 'com.polycentric.core:app:1.0'
|
implementation 'com.polycentric.core:app:1.0'
|
||||||
implementation 'com.futo.futopay:app:1.0'
|
implementation 'com.futo.futopay:app:1.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
implementation 'androidx.work:work-runtime-ktx:2.11.0'
|
||||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
|
||||||
|
|
||||||
//Database
|
//Database
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.8.3")
|
||||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.8.3")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
implementation("androidx.room:room-ktx:2.8.3")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
|
||||||
|
|
||||||
//Payment
|
//Payment
|
||||||
implementation 'com.stripe:stripe-android:20.35.1'
|
implementation 'com.stripe:stripe-android:22.0.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
|
||||||
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
|
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
|
||||||
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
|
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
|
||||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
testImplementation "org.mockito:mockito-core:5.20.0"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||||
|
|
||||||
//Rust casting SDK
|
|
||||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
|
||||||
// Polycentricandroid includes this
|
|
||||||
exclude group: 'net.java.dev.jna'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package com.futo.platformplayer
|
|
||||||
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.Opcode
|
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class FCastEncryptionTests {
|
|
||||||
@Test
|
|
||||||
fun testDHEncryptionSelf() {
|
|
||||||
val keyPair1 = FCastCastingDevice.generateKeyPair()
|
|
||||||
val keyPair2 = FCastCastingDevice.generateKeyPair()
|
|
||||||
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
|
|
||||||
|
|
||||||
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
|
|
||||||
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
|
|
||||||
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
|
|
||||||
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
|
|
||||||
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
|
|
||||||
|
|
||||||
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
|
|
||||||
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
|
|
||||||
|
|
||||||
val message = FCastPlayMessage("text/html")
|
|
||||||
val serializedBody = Json.encodeToString(message)
|
|
||||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
|
||||||
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
|
|
||||||
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
|
||||||
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals(serializedBody, decryptedMessage.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testAESKeyGeneration() {
|
|
||||||
val cases = listOf(
|
|
||||||
listOf(
|
|
||||||
//Public other
|
|
||||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
|
|
||||||
//Private self
|
|
||||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
|
|
||||||
//AES
|
|
||||||
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
|
|
||||||
),
|
|
||||||
listOf(
|
|
||||||
//Public other
|
|
||||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
|
|
||||||
//Private self
|
|
||||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
|
|
||||||
//AES
|
|
||||||
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (case in cases) {
|
|
||||||
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
|
|
||||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
|
|
||||||
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
|
||||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
|
||||||
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDHEncryptionKnown() {
|
|
||||||
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
|
|
||||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
|
|
||||||
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
|
||||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
|
||||||
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
|
||||||
|
|
||||||
val message = FCastPlayMessage("text/html")
|
|
||||||
val serializedBody = Json.encodeToString(message)
|
|
||||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
|
||||||
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals(serializedBody, decryptedMessage.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDecryptMessageKnown() {
|
|
||||||
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
|
|
||||||
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -26,6 +29,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FutoVideo"
|
android:theme="@style/Theme.FutoVideo"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:enableOnBackInvokedCallback"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
tools:targetApi="31"
|
tools:targetApi="31"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
<provider
|
<provider
|
||||||
@@ -55,9 +60,10 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustPan"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
@@ -153,30 +159,30 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.TestActivity"
|
android:name=".activities.TestActivity"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SettingsActivity"
|
android:name=".activities.SettingsActivity"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.DeveloperActivity"
|
android:name=".activities.DeveloperActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ExceptionActivity"
|
android:name=".activities.ExceptionActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.CaptchaActivity"
|
android:name=".activities.CaptchaActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.LoginActivity"
|
android:name=".activities.LoginActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceActivity"
|
android:name=".activities.AddSourceActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
@@ -189,54 +195,78 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceOptionsActivity"
|
android:name=".activities.AddSourceOptionsActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricHomeActivity"
|
android:name=".activities.PolycentricHomeActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricBackupActivity"
|
android:name=".activities.PolycentricBackupActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricCreateProfileActivity"
|
android:name=".activities.PolycentricCreateProfileActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricProfileActivity"
|
android:name=".activities.PolycentricProfileActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricWhyActivity"
|
android:name=".activities.PolycentricWhyActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricImportProfileActivity"
|
android:name=".activities.PolycentricImportProfileActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ManageTabsActivity"
|
android:name=".activities.ManageTabsActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.QRCaptureActivity"
|
android:name=".activities.QRCaptureActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.FCastGuideActivity"
|
android:name=".activities.FCastGuideActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SyncHomeActivity"
|
android:name=".activities.SyncHomeActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SyncPairActivity"
|
android:name=".activities.SyncPairActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SyncShowPairingCodeActivity"
|
android:name=".activities.SyncShowPairingCodeActivity"
|
||||||
android:screenOrientation="sensorPortrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.PolycentricModerationActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.QRCodeFullscreenActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<service
|
||||||
|
android:name=".UpdateDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.InstallUpdateActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.App.TransparentNoUi"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1025,18 +1025,21 @@
|
|||||||
|
|
||||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||||
if (true) {
|
if (true) {
|
||||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
const settings = this.Plugin?.currentPlugin?.settings;
|
||||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
if (settings) {
|
||||||
switch (setting?.type?.toLowerCase()) {
|
for (let setting of settings) {
|
||||||
case "boolean":
|
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||||
settingsToUse[setting.variable] = setting.default === 'true';
|
switch (setting?.type?.toLowerCase()) {
|
||||||
break;
|
case "boolean":
|
||||||
case "dropdown":
|
settingsToUse[setting.variable] = setting.default === 'true';
|
||||||
let dropDownIndex = parseInt(setting.default);
|
break;
|
||||||
if (dropDownIndex) {
|
case "dropdown":
|
||||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
let dropDownIndex = parseInt(setting.default);
|
||||||
}
|
if (dropDownIndex) {
|
||||||
break;
|
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -415,6 +415,8 @@ class VideoUrlSource {
|
|||||||
this.url = obj.url;
|
this.url = obj.url;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class VideoUrlWidevineSource extends VideoUrlSource {
|
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||||
@@ -512,6 +514,8 @@ class HLSSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashSource {
|
class DashSource {
|
||||||
@@ -525,6 +529,8 @@ class DashSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashWidevineSource extends DashSource {
|
class DashWidevineSource extends DashSource {
|
||||||
@@ -550,6 +556,7 @@ class DashManifestRawSource {
|
|||||||
this.language = obj.language ?? Language.UNKNOWN;
|
this.language = obj.language ?? Language.UNKNOWN;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object AppCaUpdater {
|
||||||
|
private const val CA_URL = "https://curl.se/ca/cacert.pem"
|
||||||
|
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
|
||||||
|
private const val MAX_AGE_DAYS = 30
|
||||||
|
|
||||||
|
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
|
||||||
|
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
|
||||||
|
if (needsUpdate) {
|
||||||
|
downloadToFile(CA_URL, file)
|
||||||
|
}
|
||||||
|
return@withContext file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOlderThanDays(file: File, days: Int): Boolean {
|
||||||
|
val ageMs = System.currentTimeMillis() - file.lastModified()
|
||||||
|
return ageMs > days * 24L * 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadToFile(urlStr: String, dest: File) {
|
||||||
|
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15000
|
||||||
|
readTimeout = 15000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
dest.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,10 +216,9 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||||||
return InetAddress.getByAddress(this);
|
return InetAddress.getByAddress(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
|
||||||
ensureNotMainThread()
|
ensureNotMainThread()
|
||||||
|
|
||||||
val timeout = 10000
|
|
||||||
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||||
if(addresses.isEmpty())
|
if(addresses.isEmpty())
|
||||||
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||||
@@ -232,7 +231,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
|
|||||||
val socket = Socket()
|
val socket = Socket()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||||
socket.close()
|
socket.close()
|
||||||
@@ -263,7 +262,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.connect(InetSocketAddress(address, port), timeout);
|
socket.connect(InetSocketAddress(address, port), timeoutMs);
|
||||||
|
|
||||||
synchronized(syncObject) {
|
synchronized(syncObject) {
|
||||||
if (connectedSocket == null) {
|
if (connectedSocket == null) {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
|||||||
import com.caoccao.javet.values.reference.V8ValueError
|
import com.caoccao.javet.values.reference.V8ValueError
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.caoccao.javet.values.reference.V8ValuePromise
|
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
@@ -21,7 +24,6 @@ import kotlinx.coroutines.async
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.selects.SelectClause0
|
import kotlinx.coroutines.selects.SelectClause0
|
||||||
import kotlinx.coroutines.selects.SelectClause1
|
import kotlinx.coroutines.selects.SelectClause1
|
||||||
import java.util.concurrent.CancellationException
|
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
@@ -116,14 +118,13 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
|
|||||||
inline fun V8Plugin.ensureIsBusy() {
|
inline fun V8Plugin.ensureIsBusy() {
|
||||||
this.let {
|
this.let {
|
||||||
if (!it.isThreadAlreadyBusy()) {
|
if (!it.isThreadAlreadyBusy()) {
|
||||||
//throw IllegalStateException("Tried to access V8Plugin without busy");
|
|
||||||
val stacktrace = Thread.currentThread().stackTrace;
|
val stacktrace = Thread.currentThread().stackTrace;
|
||||||
Logger.w("Extensions_V8",
|
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
|
||||||
"V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
|
", " + stacktrace.drop(4)?.firstOrNull().toString() +
|
||||||
", " + stacktrace.drop(4)?.firstOrNull().toString() +
|
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
|
||||||
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
|
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
|
||||||
", " + stacktrace.drop(6)?.firstOrNull()?.toString()
|
Logger.w("Extensions_V8", message);
|
||||||
);
|
throw IllegalStateException(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,8 +135,7 @@ inline fun V8Value.ensureIsBusy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
||||||
if(false)
|
ensureIsBusy();
|
||||||
ensureIsBusy();
|
|
||||||
return when(T::class) {
|
return when(T::class) {
|
||||||
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
||||||
Int::class -> {
|
Int::class -> {
|
||||||
@@ -184,10 +184,14 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
|
|||||||
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
|
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
|
fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
|
||||||
|
obj.ensureIsBusy();
|
||||||
|
return obj.keys.map { obj.getString(it) };
|
||||||
|
}
|
||||||
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
||||||
if(obj == null)
|
if(obj == null)
|
||||||
return hashMapOf();
|
return hashMapOf();
|
||||||
|
obj.ensureIsBusy();
|
||||||
val map = hashMapOf<String, String>();
|
val map = hashMapOf<String, String>();
|
||||||
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
||||||
map.put(prop, obj.getString(prop));
|
map.put(prop, obj.getString(prop));
|
||||||
@@ -201,21 +205,27 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
|||||||
plugin.busy {
|
plugin.busy {
|
||||||
this.register(object: IV8ValuePromise.IListener {
|
this.register(object: IV8ValuePromise.IListener {
|
||||||
override fun onFulfilled(p0: V8Value?) {
|
override fun onFulfilled(p0: V8Value?) {
|
||||||
if(p0 is V8ValueError)
|
plugin.busy {
|
||||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
if(p0 is V8ValueError)
|
||||||
else {
|
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||||
if(p0 is V8ValueObject)
|
else {
|
||||||
p0.setWeak();
|
if(p0 is V8ValueObject)
|
||||||
promiseResult = p0 as T;
|
p0.setWeak();
|
||||||
|
promiseResult = p0 as T;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
override fun onRejected(p0: V8Value?) {
|
override fun onRejected(p0: V8Value?) {
|
||||||
promiseException = p0?.toException(plugin.config);
|
plugin.busy {
|
||||||
|
promiseException = p0?.toException(plugin.config);
|
||||||
|
}
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
override fun onCatch(p0: V8Value?) {
|
override fun onCatch(p0: V8Value?) {
|
||||||
promiseException = p0?.toException(plugin.config);
|
plugin.busy {
|
||||||
|
promiseException = p0?.toException(plugin.config);
|
||||||
|
}
|
||||||
latch.countDown();
|
latch.countDown();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -227,20 +237,23 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
|||||||
}
|
}
|
||||||
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
||||||
|
|
||||||
|
val isPending = plugin.busy {
|
||||||
if(!promise.isPending) {
|
promise.isPending
|
||||||
try {
|
};
|
||||||
Logger.i("V8", "V8Promise resolved synchronously");
|
if(!isPending) {
|
||||||
if(promise.isFulfilled)
|
plugin.busy {
|
||||||
promiseResult = promise.getResult<T>();
|
try {
|
||||||
else
|
Logger.i("V8", "V8Promise resolved synchronously");
|
||||||
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
if(promise.isFulfilled)
|
||||||
|
promiseResult = promise.getResult<T>();
|
||||||
|
else
|
||||||
|
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
promiseException = ex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
} else {
|
||||||
promiseException = ex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
plugin.unbusy {
|
plugin.unbusy {
|
||||||
latch.await();
|
latch.await();
|
||||||
}
|
}
|
||||||
@@ -264,16 +277,35 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
|||||||
plugin.busy {
|
plugin.busy {
|
||||||
this.register(object: IV8ValuePromise.IListener {
|
this.register(object: IV8ValuePromise.IListener {
|
||||||
override fun onFulfilled(p0: V8Value?) {
|
override fun onFulfilled(p0: V8Value?) {
|
||||||
plugin.resolvePromise(promise);
|
plugin.busy {
|
||||||
underlyingDef.complete(p0 as T);
|
plugin.resolvePromise(promise);
|
||||||
|
underlyingDef.complete(p0 as T);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onRejected(p0: V8Value?) {
|
override fun onRejected(p0: V8Value?) {
|
||||||
plugin.resolvePromise(promise);
|
try {
|
||||||
underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented.."));
|
plugin.busy {
|
||||||
|
plugin.resolvePromise(promise);
|
||||||
|
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
|
||||||
|
Logger.i("V8", "Promise rejected, setting exception");
|
||||||
|
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("V8", "Rejection handling failed?" , ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
override fun onCatch(p0: V8Value?) {
|
override fun onCatch(p0: V8Value?) {
|
||||||
plugin.resolvePromise(promise);
|
try {
|
||||||
underlyingDef.completeExceptionally(p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented.."));
|
plugin.busy {
|
||||||
|
plugin.resolvePromise(promise);
|
||||||
|
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
|
||||||
|
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("V8", "Catching handling failed?" , ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -285,15 +317,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||||
|
ensureIsBusy();
|
||||||
val p0 = this;
|
val p0 = this;
|
||||||
if(p0 is V8ValueObject) {
|
if(p0 is V8ValueObject) {
|
||||||
|
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
|
||||||
|
/*
|
||||||
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
||||||
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||||
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||||
return Exception("Promise Failed: " + pluginType + msg);
|
return Throwable("Promise Failed: " + pluginType + msg);
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
else if(p0 is V8ValueString)
|
else if(p0 is V8ValueString)
|
||||||
return Exception("Promise Failed:" + p0.value);
|
return Throwable("Promise Failed:" + p0.value);
|
||||||
else
|
else
|
||||||
return NotImplementedError("onCatch promise not implemented..");
|
return NotImplementedError("onCatch promise not implemented..");
|
||||||
}
|
}
|
||||||
@@ -331,6 +367,7 @@ class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Defer
|
|||||||
|
|
||||||
|
|
||||||
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
||||||
|
ensureIsBusy();
|
||||||
var result = this.invoke<V8Value>(method, *obj);
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
if(result is V8ValuePromise) {
|
if(result is V8ValuePromise) {
|
||||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||||
@@ -338,6 +375,7 @@ fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
|||||||
return result as T;
|
return result as T;
|
||||||
}
|
}
|
||||||
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
|
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
|
||||||
|
ensureIsBusy();
|
||||||
var result = this.invoke<V8Value>(method, *obj);
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
if(result is V8ValuePromise) {
|
if(result is V8ValuePromise) {
|
||||||
return result.toV8ValueAsync(this.getSourcePlugin()!!);
|
return result.toV8ValueAsync(this.getSourcePlugin()!!);
|
||||||
@@ -345,6 +383,7 @@ fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?):
|
|||||||
return V8Deferred(CompletableDeferred(result as T));
|
return V8Deferred(CompletableDeferred(result as T));
|
||||||
}
|
}
|
||||||
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
||||||
|
ensureIsBusy();
|
||||||
var result = this.invoke<V8Value>(method, *obj);
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
if(result is V8ValuePromise) {
|
if(result is V8ValuePromise) {
|
||||||
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||||
@@ -352,10 +391,34 @@ fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
|
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
|
||||||
|
ensureIsBusy();
|
||||||
var result = this.invoke<V8Value>(method, *obj);
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
if(result is V8ValuePromise) {
|
if(result is V8ValuePromise) {
|
||||||
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
|
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return V8Deferred(CompletableDeferred(result));
|
return V8Deferred(CompletableDeferred(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
|
||||||
|
try {
|
||||||
|
return this.await();
|
||||||
|
}
|
||||||
|
catch(ex: CancellationException) {
|
||||||
|
if(ex.cause != null) {
|
||||||
|
throw ex.cause!!;
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> IPager<T>.toList(): List<T> {
|
||||||
|
val list = this.getResults().toMutableList();
|
||||||
|
|
||||||
|
while(this.hasMorePages()) {
|
||||||
|
this.nextPage();
|
||||||
|
list.addAll(this.getResults());
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toList();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.Window
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat.Type
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.core.view.doOnAttach
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class RootInsetsController private constructor(
|
||||||
|
private val activity: Activity,
|
||||||
|
private val window: Window,
|
||||||
|
private val root: ViewGroup
|
||||||
|
) {
|
||||||
|
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
|
||||||
|
|
||||||
|
private val basePaddingLeft = root.paddingLeft
|
||||||
|
private val basePaddingTop = root.paddingTop
|
||||||
|
private val basePaddingRight = root.paddingRight
|
||||||
|
private val basePaddingBottom = root.paddingBottom
|
||||||
|
|
||||||
|
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
|
||||||
|
private var fullscreen = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
controller.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||||
|
currentInsets = insets
|
||||||
|
applyPadding()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun effectiveInsets(): Insets {
|
||||||
|
if (fullscreen) return Insets.NONE
|
||||||
|
|
||||||
|
val sys = currentInsets.getInsets(Type.systemBars())
|
||||||
|
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
|
||||||
|
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
|
||||||
|
|
||||||
|
val top = if (portrait) max(sys.top, cut.top) else sys.top
|
||||||
|
return Insets.of(sys.left, top, sys.right, sys.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun applyPadding() {
|
||||||
|
val e = effectiveInsets()
|
||||||
|
root.updatePadding(
|
||||||
|
left = basePaddingLeft + e.left,
|
||||||
|
top = basePaddingTop + e.top,
|
||||||
|
right = basePaddingRight + e.right,
|
||||||
|
bottom = basePaddingBottom + e.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forceRelayoutAndInsets() {
|
||||||
|
root.post {
|
||||||
|
ViewCompat.requestApplyInsets(root)
|
||||||
|
applyPadding()
|
||||||
|
root.post {
|
||||||
|
ViewCompat.requestApplyInsets(root)
|
||||||
|
applyPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
|
||||||
|
fullscreen = true
|
||||||
|
if (allowCutoutShortEdges) {
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.hide(Type.systemBars())
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exitFullscreen() {
|
||||||
|
fullscreen = false
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||||
|
}
|
||||||
|
controller.show(Type.systemBars())
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfigurationChanged() {
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
|
||||||
|
controller.isAppearanceLightStatusBars = lightStatus
|
||||||
|
controller.isAppearanceLightNavigationBars = lightNav
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
|
||||||
|
return RootInsetsController(activity, activity.window, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,16 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
|
||||||
import com.futo.platformplayer.activities.SyncHomeActivity
|
import com.futo.platformplayer.activities.SyncHomeActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||||
@@ -42,7 +43,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -64,7 +64,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
||||||
@FormFieldButton(R.drawable.ic_update)
|
@FormFieldButton(R.drawable.ic_update)
|
||||||
fun syncGrayjay() {
|
fun syncGrayjay() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp?.instance?.activity?.let {
|
||||||
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||||
@FormFieldButton(R.drawable.ic_person)
|
@FormFieldButton(R.drawable.ic_person)
|
||||||
fun managePolycentricIdentity() {
|
fun managePolycentricIdentity() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp?.instance?.activity?.let {
|
||||||
if (StatePolycentric.instance.enabled) {
|
if (StatePolycentric.instance.enabled) {
|
||||||
if (StatePolycentric.instance.processHandle != null) {
|
if (StatePolycentric.instance.processHandle != null) {
|
||||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||||
@@ -91,7 +91,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun openFAQ() {
|
fun openFAQ() {
|
||||||
try {
|
try {
|
||||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
||||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
//Ignored
|
//Ignored
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun openIssues() {
|
fun openIssues() {
|
||||||
try {
|
try {
|
||||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
||||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
//Ignored
|
//Ignored
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormFieldButton(R.drawable.ic_tabs)
|
@FormFieldButton(R.drawable.ic_tabs)
|
||||||
fun manageTabs() {
|
fun manageTabs() {
|
||||||
try {
|
try {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp?.instance?.activity?.let {
|
||||||
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -145,7 +145,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||||
@FormFieldButton(R.drawable.ic_move_up)
|
@FormFieldButton(R.drawable.ic_move_up)
|
||||||
fun import() {
|
fun import() {
|
||||||
val act = SettingsActivity.getActivity() ?: return;
|
val act = StateApp.instance.activity ?: return;
|
||||||
val intent = MainActivity.getImportOptionsIntent(act);
|
val intent = MainActivity.getImportOptionsIntent(act);
|
||||||
act.startActivity(intent);
|
act.startActivity(intent);
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormFieldButton(R.drawable.ic_link)
|
@FormFieldButton(R.drawable.ic_link)
|
||||||
fun manageLinks() {
|
fun manageLinks() {
|
||||||
try {
|
try {
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to show url handling prompt", e)
|
Logger.e(TAG, "Failed to show url handling prompt", e)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
||||||
@FormFieldButton(R.drawable.battery_full_24px)
|
@FormFieldButton(R.drawable.battery_full_24px)
|
||||||
fun ignoreBatteryOptimization() {
|
fun ignoreBatteryOptimization() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
val intent = Intent()
|
val intent = Intent()
|
||||||
val packageName = it.packageName
|
val packageName = it.packageName
|
||||||
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||||
@@ -244,7 +244,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun clearHidden() {
|
fun clearHidden() {
|
||||||
StateMeta.instance.removeAllHiddenCreators();
|
StateMeta.instance.removeAllHiddenCreators();
|
||||||
StateMeta.instance.removeAllHiddenVideos();
|
StateMeta.instance.removeAllHiddenVideos();
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,7 +313,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var showSubscriptionGroups: Boolean = true;
|
var showSubscriptionGroups: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
||||||
var useSubscriptionExchange: Boolean = false;
|
var useSubscriptionExchange: Boolean = true;
|
||||||
|
|
||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
@@ -374,9 +374,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||||
fun clearChannelCache() {
|
fun clearChannelCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
|
||||||
StateCache.instance.clear();
|
StateCache.instance.clear();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
fun getPrimaryLanguage(context: Context): String? {
|
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||||
return when(primaryLanguage) {
|
return when(primaryLanguage) {
|
||||||
0 -> "en";
|
0 -> "en";
|
||||||
1 -> "es";
|
1 -> "es";
|
||||||
@@ -401,13 +401,18 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "id";
|
8 -> "id";
|
||||||
9 -> "hi";
|
9 -> "hi";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
11 -> "tu";
|
11 -> "tr";
|
||||||
12 -> "ru";
|
12 -> "ru";
|
||||||
13 -> "pt";
|
13 -> "pt";
|
||||||
14 -> "zh";
|
14 -> "zh";
|
||||||
|
15 -> "it";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
|
||||||
|
var stickySubtitles: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
||||||
var preferOriginalAudio: Boolean = true;
|
var preferOriginalAudio: Boolean = true;
|
||||||
|
|
||||||
@@ -426,6 +431,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
6 -> 1.75f;
|
6 -> 1.75f;
|
||||||
7 -> 2.0f;
|
7 -> 2.0f;
|
||||||
8 -> 2.25f;
|
8 -> 2.25f;
|
||||||
|
9 -> 2.5f;
|
||||||
|
10 -> 2.75f;
|
||||||
|
11 -> 3.0f;
|
||||||
else -> 1.0f;
|
else -> 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -719,11 +727,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var allowLinkLocalIpv4: Boolean = false;
|
var allowLinkLocalIpv4: Boolean = false;
|
||||||
|
|
||||||
@AdvancedField
|
|
||||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
|
||||||
var experimentalCasting: Boolean = false
|
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@@ -756,7 +759,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
try {
|
try {
|
||||||
if (!Logger.submitLogs()) {
|
if (!Logger.submitLogs()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -773,7 +776,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||||
fun resetAnnouncements() {
|
fun resetAnnouncements() {
|
||||||
StateAnnouncement.instance.resetAnnouncements();
|
StateAnnouncement.instance.resetAnnouncements();
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,7 +789,6 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||||
@Transient
|
|
||||||
var plugins = Plugins();
|
var plugins = Plugins();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Plugins {
|
class Plugins {
|
||||||
@@ -795,6 +797,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||||
var checkDisabledPluginsForUpdates: Boolean = false;
|
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
|
||||||
|
var clearCookiesAfterLogin: Boolean = false;
|
||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||||
var clearCookiesOnLogout: Boolean = true;
|
var clearCookiesOnLogout: Boolean = true;
|
||||||
@@ -804,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||||
cookieManager.removeAllCookies(null);
|
cookieManager.removeAllCookies(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shouldClearWebviewCookies(): Boolean {
|
||||||
|
return clearCookiesAfterLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||||
fun reinstallEmbedded() {
|
fun reinstallEmbedded() {
|
||||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||||
@@ -841,13 +852,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
||||||
fun changeStorageGeneral() {
|
fun changeStorageGeneral() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
||||||
fun changeStorageDownload() {
|
fun changeStorageDownload() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,7 +867,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun clearStorageDownload() {
|
fun clearStorageDownload() {
|
||||||
Settings.instance.storage.storage_download = null;
|
Settings.instance.storage.storage_download = null;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,9 +880,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||||
var check: Int = 0;
|
var check: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||||
@DropdownFieldOptionsId(R.array.background_download)
|
//@DropdownFieldOptionsId(R.array.background_download)
|
||||||
var backgroundDownload: Int = 0;
|
var shouldBackgroundDownload: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||||
@DropdownFieldOptionsId(R.array.when_download)
|
@DropdownFieldOptionsId(R.array.when_download)
|
||||||
@@ -893,13 +904,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||||
fun manualCheck() {
|
fun manualCheck() {
|
||||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StateUpdate.instance.checkForUpdates(it, true)
|
StateUpdate.instance.checkForUpdates(it, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
try {
|
try {
|
||||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
@@ -911,7 +922,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||||
fun viewChangelog() {
|
fun viewChangelog() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
@@ -952,21 +963,34 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||||
var didAskAutoBackup: Boolean = false;
|
var didAskAutoBackup: Boolean = false;
|
||||||
|
var autoBackupEnabled: Boolean = false
|
||||||
var autoBackupPassword: String? = null;
|
var autoBackupPassword: String? = null;
|
||||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
fun shouldAutomaticBackup() = autoBackupEnabled
|
||||||
|
|
||||||
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
||||||
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
||||||
|
|
||||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||||
fun configureAutomaticBackup() {
|
fun configureAutomaticBackup() {
|
||||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
StateApp.instance.activity?.let { activity ->
|
||||||
SettingsActivity.getActivity()?.reloadSettings();
|
if(!Settings.instance.storage.isStorageMainValid(activity)) {
|
||||||
};
|
UIDialogs.toast("Missing general directory")
|
||||||
|
StateApp.instance.changeExternalGeneralDirectory(activity) {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||||
fun restoreAutomaticBackup() {
|
fun restoreAutomaticBackup() {
|
||||||
val activity = SettingsActivity.getActivity()!!
|
val activity = StateApp.instance.activity!!
|
||||||
|
|
||||||
if(!StateBackup.hasAutomaticBackup())
|
if(!StateBackup.hasAutomaticBackup())
|
||||||
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
||||||
@@ -977,8 +1001,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
||||||
fun export() {
|
fun export() {
|
||||||
val activity = SettingsActivity.getActivity() ?: return;
|
val activity = StateApp.instance.activity ?: return;
|
||||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
val fragView = SettingsFragment.currentView ?: return;
|
||||||
|
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
|
||||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||||
StateBackup.shareExternalBackup();
|
StateBackup.shareExternalBackup();
|
||||||
}),
|
}),
|
||||||
@@ -994,11 +1019,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class Payment {
|
class Payment {
|
||||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
@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";
|
val paymentStatus: String get() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||||
|
|
||||||
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||||
fun viewLicenseStatus() {
|
fun viewLicenseStatus() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
try {
|
try {
|
||||||
if (StatePayment.instance.hasPaid) {
|
if (StatePayment.instance.hasPaid) {
|
||||||
val paymentKey = StatePayment.instance.getPaymentKey()
|
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||||
@@ -1014,12 +1039,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||||
fun clearPayment() {
|
fun clearPayment() {
|
||||||
SettingsActivity.getActivity()?.let { context ->
|
StateApp.instance.activity?.let { context ->
|
||||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||||
StatePayment.instance.clearLicenses();
|
StatePayment.instance.clearLicenses();
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||||
it.reloadSettings();
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1070,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
var polycentricLocalCache: Boolean = true;
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||||
@@ -1116,7 +1142,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@AdvancedField
|
@AdvancedField
|
||||||
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||||
fun configureSyncServer() {
|
fun configureSyncServer() {
|
||||||
SettingsActivity.getActivity()?.let { context ->
|
StateApp.instance.activity?.let { context ->
|
||||||
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||||
"Enter the url to your relay server",
|
"Enter the url to your relay server",
|
||||||
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||||
@@ -1127,13 +1153,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
UIDialogs.Action("Reset", {
|
UIDialogs.Action("Reset", {
|
||||||
syncServerUrl = null;
|
syncServerUrl = null;
|
||||||
instance.save();
|
instance.save();
|
||||||
context.reloadSettings();
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
UIDialogs.toast("Sync server changes require a restart");
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
}, UIDialogs.ActionStyle.ACCENT),
|
}, UIDialogs.ActionStyle.ACCENT),
|
||||||
UIDialogs.Action.withInput("Configure", {
|
UIDialogs.Action.withInput("Configure", {
|
||||||
syncServerUrl = it?.text
|
syncServerUrl = it?.text
|
||||||
instance.save();
|
instance.save();
|
||||||
context.reloadSettings();
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
UIDialogs.toast("Sync server changes require a restart");
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
}, UIDialogs.ActionStyle.PRIMARY),
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
|
|||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
import com.caoccao.javet.values.primitive.V8ValueString
|
||||||
import com.futo.platformplayer.activities.DeveloperActivity
|
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -20,6 +18,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
|||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
fun subscriptionsCache5000() {
|
fun subscriptionsCache5000() {
|
||||||
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Started caching 5000 sub items"
|
"Started caching 5000 sub items"
|
||||||
);
|
);
|
||||||
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
|
val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
|
||||||
if(button is ButtonField)
|
if(button is ButtonField)
|
||||||
button.setButtonEnabled(false);
|
button.setButtonEnabled(false);
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
val diff = System.currentTimeMillis() - lastToast;
|
val diff = System.currentTimeMillis() - lastToast;
|
||||||
lastToast = System.currentTimeMillis();
|
lastToast = System.currentTimeMillis();
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"FINISHED Page: ${page}, Total: ${total}"
|
"FINISHED Page: ${page}, Total: ${total}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
fun historyCache100() {
|
fun historyCache100() {
|
||||||
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Started caching 100 history items (from home)"
|
"Started caching 100 history items (from home)"
|
||||||
);
|
);
|
||||||
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
|
val button = DeveloperFragment.currentView?.getField("history_cache_button");
|
||||||
if(button is ButtonField)
|
if(button is ButtonField)
|
||||||
button.setButtonEnabled(false);
|
button.setButtonEnabled(false);
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
val diff = System.currentTimeMillis() - lastToast;
|
val diff = System.currentTimeMillis() - lastToast;
|
||||||
lastToast = System.currentTimeMillis();
|
lastToast = System.currentTimeMillis();
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"FINISHED Page: ${page}, Total: ${total}"
|
"FINISHED Page: ${page}, Total: ${total}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
fun triggerBackgroundUpdate() {
|
fun triggerBackgroundUpdate() {
|
||||||
val act = SettingsActivity.getActivity()!!;
|
val act = StateApp.instance.activity!!;
|
||||||
try {
|
try {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
|
||||||
|
|
||||||
val wm = WorkManager.getInstance(act);
|
val wm = WorkManager.getInstance(act);
|
||||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||||
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
fun clearChannelContentCache() {
|
fun clearChannelContentCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
|
||||||
StateCache.instance.clearToday();
|
StateCache.instance.clearToday();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -165,27 +166,42 @@ class UIDialogs {
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
|
||||||
val dialogAction: ()->Unit = {
|
val dialogAction: () -> Unit = {
|
||||||
val dialog = AutomaticBackupDialog(context);
|
val dialog = AutomaticBackupDialog(context)
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog)
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
dialog.setOnDismissListener {
|
||||||
dialog.show();
|
registerDialogClosed(dialog)
|
||||||
};
|
onClosed?.invoke()
|
||||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
}
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
|
dialog.show()
|
||||||
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
|
}
|
||||||
UIDialogs.Action(context.getString(R.string.override), {
|
|
||||||
dialogAction();
|
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context,
|
||||||
|
R.drawable.ic_move_up,
|
||||||
|
context.getString(R.string.an_old_backup_is_available),
|
||||||
|
context.getString(R.string.would_you_like_to_restore_this_backup),
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
UIDialogs.Action(context.getString(R.string.cancel), {}),
|
||||||
|
UIDialogs.Action(context.getString(R.string.continue_anyway), {
|
||||||
|
dialogAction()
|
||||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||||
UIDialogs.Action(context.getString(R.string.restore), {
|
UIDialogs.Action(context.getString(R.string.restore), {
|
||||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
|
||||||
|
?: StateApp.instance.scopeOrNull
|
||||||
|
?: StateApp.instance.scope
|
||||||
|
|
||||||
|
UIDialogs.showAutomaticRestoreDialog(context, scope)
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
)
|
||||||
else {
|
} else {
|
||||||
dialogAction();
|
dialogAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||||
val dialog = AutomaticRestoreDialog(context, scope);
|
val dialog = AutomaticRestoreDialog(context, scope);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
@@ -370,17 +386,19 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||||
|
setOnDismissListener { dismissAction?.invoke() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||||
@@ -403,13 +421,6 @@ class UIDialogs {
|
|||||||
dialog.setMaxVersion(lastVersion);
|
dialog.setMaxVersion(lastVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
|
|
||||||
val dialog = AutoUpdateDialog(context);
|
|
||||||
registerDialogOpened(dialog);
|
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
|
||||||
dialog.showPredownloaded(apkFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||||
if(!store.hasMissingReconstructions())
|
if(!store.hasMissingReconstructions())
|
||||||
onConcluded();
|
onConcluded();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
@@ -14,7 +15,6 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
|||||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@@ -32,6 +32,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
|||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
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.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.downloads.VideoLocal
|
import com.futo.platformplayer.downloads.VideoLocal
|
||||||
@@ -74,6 +75,9 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
|
||||||
|
import kotlin.collections.toList
|
||||||
|
|
||||||
class UISlideOverlays {
|
class UISlideOverlays {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -331,15 +335,9 @@ class UISlideOverlays {
|
|||||||
0,
|
0,
|
||||||
UIDialogs.Action("Cancel", {}),
|
UIDialogs.Action("Cancel", {}),
|
||||||
UIDialogs.Action("Configure", {
|
UIDialogs.Action("Configure", {
|
||||||
val intent = Intent(
|
StateApp.instance.activity?.let {
|
||||||
mainContext,
|
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
|
||||||
SettingsActivity::class.java
|
}
|
||||||
);
|
|
||||||
intent.putExtra(
|
|
||||||
"query",
|
|
||||||
mainContext.getString(R.string.background_update)
|
|
||||||
);
|
|
||||||
mainContext.startActivity(intent);
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -385,7 +383,8 @@ class UISlideOverlays {
|
|||||||
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl)
|
val modifier = if (source is JSSource && source.hasRequestModifier) source.getRequestModifier() else null
|
||||||
|
val masterPlaylistResponse = ManagedHttpClient().get(sourceUrl, HashMap(), modifier)
|
||||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
@@ -518,7 +517,7 @@ class UISlideOverlays {
|
|||||||
|
|
||||||
slideUpMenuOverlay.onOK.subscribe {
|
slideUpMenuOverlay.onOK.subscribe {
|
||||||
//TODO: Fix SubtitleRawSource issue
|
//TODO: Fix SubtitleRawSource issue
|
||||||
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null);
|
StateDownloads.instance.download(video, selectedVideoVariant, selectedAudioVariant, null, videoModifier = modifier, audioModifier = modifier);
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,11 +528,11 @@ class UISlideOverlays {
|
|||||||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (source is IHLSManifestSource) {
|
if (source is IHLSManifestSource) {
|
||||||
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
|
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null, videoModifier = modifier)
|
||||||
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
UIDialogs.toast(container.context, "Variant video HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else if (source is IHLSManifestAudioSource) {
|
} else if (source is IHLSManifestAudioSource) {
|
||||||
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
|
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null, audioModifier = modifier)
|
||||||
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
|
||||||
slideUpMenuOverlay.hide()
|
slideUpMenuOverlay.hide()
|
||||||
} else {
|
} else {
|
||||||
@@ -579,6 +578,51 @@ class UISlideOverlays {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val allLanguages = videoSources?.map { it.language }?.distinct() ?: listOf();
|
||||||
|
val langResCombinations = if(videoSources != null) allLanguages.flatMap {
|
||||||
|
lang -> videoSources
|
||||||
|
.filter { v -> v.language == lang }
|
||||||
|
.map { it.height * it.width }
|
||||||
|
.distinct()
|
||||||
|
.map { res -> Pair(res, lang) }
|
||||||
|
} else listOf();
|
||||||
|
var videoSourceItems = mutableListOf<SlideUpMenuItem>();
|
||||||
|
var selectedLanguage: String? = null;
|
||||||
|
val languageFilters = if(allLanguages.filter { it != null }.count() > 1)
|
||||||
|
SlideUpMenuButtonList(container.context, null, "language_filter", true).apply {
|
||||||
|
var languageFilterLabels = allLanguages.filterNotNull().toList();
|
||||||
|
val english = languageFilterLabels.find { it?.lowercase() == "en" };
|
||||||
|
val originalLanguage = videoSources?.find { it.original == true }?.language;
|
||||||
|
val primaryLanguage = Settings.instance.playback.getPrimaryLanguage();
|
||||||
|
val hasPrimaryLanguage = videoSources?.any { it.language == primaryLanguage } ?: false;
|
||||||
|
|
||||||
|
if(english != null)
|
||||||
|
languageFilterLabels = listOf(english).plus(languageFilterLabels.filter { it != english }).toList();
|
||||||
|
if(primaryLanguage != null && languageFilterLabels.contains(primaryLanguage))
|
||||||
|
languageFilterLabels = listOf(primaryLanguage).plus(languageFilterLabels.filter { it != primaryLanguage }).toList();
|
||||||
|
if(originalLanguage != null)
|
||||||
|
languageFilterLabels = listOf(originalLanguage).plus(languageFilterLabels.filter { it != originalLanguage }).toList();
|
||||||
|
Log.i(TAG, "Language filtesr: ${languageFilterLabels.joinToString(", ")}");
|
||||||
|
selectedLanguage = originalLanguage ?: (if(hasPrimaryLanguage) primaryLanguage else null);
|
||||||
|
setButtons(languageFilterLabels, selectedLanguage);
|
||||||
|
onClick.subscribe { selected ->
|
||||||
|
setSelected(selected);
|
||||||
|
|
||||||
|
videoSourceItems.forEach {
|
||||||
|
val item = it.itemTag;
|
||||||
|
if(item is IVideoSource) {
|
||||||
|
if(item.language == selected)
|
||||||
|
it.visibility = View.VISIBLE;
|
||||||
|
else
|
||||||
|
it.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else null;
|
||||||
|
|
||||||
|
if(languageFilters != null) items.add(languageFilters)
|
||||||
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
|
||||||
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
listOf((if (audioSources != null) listOf(SlideUpMenuItem(
|
||||||
container.context,
|
container.context,
|
||||||
@@ -615,7 +659,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is JSDashManifestRawSource -> {
|
is JSDashManifestRawSource -> {
|
||||||
@@ -635,7 +685,13 @@ class UISlideOverlays {
|
|||||||
menu?.setOk(container.context.getString(R.string.download));
|
menu?.setOk(container.context.getString(R.string.download));
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is IHLSManifestSource -> {
|
is IHLSManifestSource -> {
|
||||||
@@ -649,7 +705,13 @@ class UISlideOverlays {
|
|||||||
showHlsPicker(video, it, it.url, container)
|
showHlsPicker(video, it, it.url, container)
|
||||||
},
|
},
|
||||||
invokeParent = false
|
invokeParent = false
|
||||||
)
|
).apply {
|
||||||
|
videoSourceItems.add(this);
|
||||||
|
if(selectedLanguage != null) {
|
||||||
|
if(it.language != selectedLanguage)
|
||||||
|
this.visibility = View.GONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, cancelIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient()
|
||||||
|
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||||
|
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(applicationContext, serviceIntent)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateCheckWorker"
|
||||||
|
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateDownloadService"
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_CANCEL = "cancel"
|
||||||
|
private const val MAX_RETRIES = 5
|
||||||
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDownloading: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
|
cancelRequested = true
|
||||||
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
StateUpdate.Companion.instance.clearUi()
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloading = true
|
||||||
|
cancelRequested = false
|
||||||
|
|
||||||
|
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
|
||||||
|
|
||||||
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
downloadApk(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val force = progress == 100 && !indeterminate
|
||||||
|
|
||||||
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
|
lastProgressUpdateElapsedMs = now
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||||
|
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
|
||||||
|
|
||||||
|
if(onProgress != null)
|
||||||
|
onProgress.invoke(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadApk(version: Int) {
|
||||||
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
var announcement: SessionAnnouncement? = null;
|
||||||
|
try {
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||||
|
ImageVariable.fromResource(R.drawable.foreground));
|
||||||
|
}
|
||||||
|
catch(ex: Exception){
|
||||||
|
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
|
||||||
|
try {
|
||||||
|
if (announcement != null)
|
||||||
|
announcement?.setProgress(it);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cancelRequested) {
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
|
if (!partialFile.renameTo(apkFile)) {
|
||||||
|
throw IllegalStateException("Failed to rename partial APK file")
|
||||||
|
}
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled by user", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(version, t.message)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (announcement != null) {
|
||||||
|
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){}
|
||||||
|
isDownloading = false
|
||||||
|
cancelRequested = false
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||||
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
if (startOffset > 0L) {
|
||||||
|
setRequestProperty("Range", "bytes=$startOffset-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connect()
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||||
|
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||||
|
partialFile.delete()
|
||||||
|
startOffset = 0L
|
||||||
|
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||||
|
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentLength = connection.contentLengthLong
|
||||||
|
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||||
|
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgress = -1
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||||
|
while (!cancelRequested) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||||
|
if (progress != lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
val safeProgress = when {
|
||||||
|
progress < 0 -> 0
|
||||||
|
progress > 100 -> 100
|
||||||
|
else -> progress
|
||||||
|
}
|
||||||
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelRequested && totalBytes > 0L) {
|
||||||
|
val finalProgress = 100
|
||||||
|
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelRequested) {
|
||||||
|
throw CancellationException("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||||
|
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
StateUpdate.Companion.instance.setUiReady(version, apkFile)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val ctx = applicationContext
|
||||||
|
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to register install announcement", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
|
||||||
|
object UpdateInstaller {
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = "package:${context.packageName}".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var session: PackageInstaller.Session? = null
|
||||||
|
try {
|
||||||
|
val dataLength = apkFile.length()
|
||||||
|
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
|
||||||
|
if (usable in 0 until dataLength) {
|
||||||
|
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
|
||||||
|
Logger.w(TAG, msg)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, msg)
|
||||||
|
}
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
inputStream = apkFile.inputStream()
|
||||||
|
|
||||||
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||||
|
}
|
||||||
|
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val statusReceiver = pendingIntent.intentSender
|
||||||
|
|
||||||
|
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||||
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
onReceiveResult(context, version, apkFile, message);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
|
session.commit(statusReceiver)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
|
session?.abandon()
|
||||||
|
|
||||||
|
val raw = e.message ?: ""
|
||||||
|
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
|
||||||
|
"Not enough storage to install update. Free up some space and try again."
|
||||||
|
} else {
|
||||||
|
"Failed to install update: $raw"
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, friendly)
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
|
||||||
|
} finally {
|
||||||
|
session?.close()
|
||||||
|
inputStream?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||||
|
try {
|
||||||
|
InstallReceiver.onReceiveResult.remove(this)
|
||||||
|
|
||||||
|
if (result.isNullOrEmpty()) {
|
||||||
|
Logger.i(TAG, "Update install finished successfully")
|
||||||
|
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||||
|
StateUpdate.instance.clearUi()
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Update install failed: $result")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||||
|
StateUpdate.instance.setUiReady(version, apkFile)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle install result", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object UpdateNotificationManager {
|
||||||
|
private const val CHANNEL_ID = "app_updates"
|
||||||
|
private const val CHANNEL_NAME = "App updates"
|
||||||
|
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||||
|
|
||||||
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
|
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
|
const val NOTIF_ID_READY = 2003
|
||||||
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
|
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchPendingIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update installed")
|
||||||
|
.setContentText("Version $version installed. Tap to open.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
if (launchPendingIntent != null) {
|
||||||
|
builder.setContentIntent(launchPendingIntent)
|
||||||
|
builder.addAction(0, "Open app", launchPendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_CANCEL
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val cancelPendingIntent = getBroadcast(
|
||||||
|
context,
|
||||||
|
3,
|
||||||
|
cancelIntent,
|
||||||
|
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Downloading update")
|
||||||
|
.setContentText("Downloading version $version")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
|
if (indeterminate) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update downloaded")
|
||||||
|
.setContentText("Tap to install version $version.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to download update")
|
||||||
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
return
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to install update")
|
||||||
|
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.addAction(0, "Install again", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.icu.util.Output
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
@@ -44,6 +42,9 @@ import java.util.*
|
|||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
import java.util.zip.GZIPOutputStream
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@@ -101,7 +102,7 @@ fun String.isHexColor(): Boolean {
|
|||||||
|
|
||||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||||
|
|
||||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||||
|
|
||||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||||
@@ -114,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
|||||||
it.flush();
|
it.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
fun loadBitmap(url: String): Bitmap {
|
|
||||||
try {
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val response = client.get(url);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
val bitmapStream = response.body.byteStream();
|
|
||||||
val bitmap = BitmapFactory.decodeStream(bitmapStream);
|
|
||||||
return bitmap;
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to find data at URL.");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||||
}
|
}
|
||||||
@@ -458,4 +442,11 @@ fun addressScore(addr: InetAddress): Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||||
|
|
||||||
|
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||||
|
return this;
|
||||||
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
|
//.override(maxSizePx, maxSizePx)
|
||||||
|
//.centerInside()
|
||||||
|
}
|
||||||
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
|
|||||||
onNewIntent(intent);
|
onNewIntent(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
var url = intent?.dataString;
|
var url = intent.dataString;
|
||||||
|
|
||||||
if(url == null)
|
if(url == null)
|
||||||
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
||||||
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
|||||||
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
|
|||||||
intent.getStringExtra("body");
|
intent.getStringExtra("body");
|
||||||
else null;
|
else null;
|
||||||
|
|
||||||
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (captchaConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = captchaConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
|
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
|
||||||
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
_callback = null;
|
_callback = null;
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package com.futo.platformplayer.activities
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.futo.platformplayer.*
|
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
|
||||||
import com.futo.platformplayer.views.fields.IField
|
|
||||||
|
|
||||||
class DeveloperActivity : AppCompatActivity() {
|
|
||||||
private lateinit var _form: FieldForm;
|
|
||||||
private lateinit var _buttonBack: ImageButton;
|
|
||||||
|
|
||||||
fun getField(id: String): IField? {
|
|
||||||
return _form.findField(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
DeveloperActivity._lastActivity = this;
|
|
||||||
setContentView(R.layout.activity_dev);
|
|
||||||
setNavigationBarColorAndIcons();
|
|
||||||
|
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
|
||||||
_form = findViewById(R.id.settings_form);
|
|
||||||
|
|
||||||
_form.fromObject(SettingsDev.instance);
|
|
||||||
_form.onChanged.subscribe { _, _ ->
|
|
||||||
_form.setObjectValues();
|
|
||||||
SettingsDev.instance.save();
|
|
||||||
};
|
|
||||||
|
|
||||||
_buttonBack.setOnClickListener {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
|
||||||
super.finish()
|
|
||||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
//TODO: Temporary for solving Settings issues
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private var _lastActivity: DeveloperActivity? = null;
|
|
||||||
|
|
||||||
fun getActivity(): DeveloperActivity? {
|
|
||||||
val act = _lastActivity;
|
|
||||||
if(act != null)
|
|
||||||
return act;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateInstaller
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InstallUpdateActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(this)
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||||
|
|
||||||
|
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||||
|
UIDialogs.Companion.toast(this, "Update file missing")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateInstaller.startInstall(this, version, apkFile)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||||
|
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
else throw IllegalStateException("No valid configuration?");
|
else throw IllegalStateException("No valid configuration?");
|
||||||
//TODO: Backwards compat removal?
|
//TODO: Backwards compat removal?
|
||||||
|
|
||||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (authConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = authConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||||
|
|
||||||
webViewClient.onLogin.subscribe { auth ->
|
webViewClient.onLogin.subscribe { auth ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -16,7 +17,6 @@ import android.os.StrictMode.VmPolicy
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
@@ -36,6 +36,7 @@ import androidx.lifecycle.withStateAtLeast
|
|||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.futo.platformplayer.BuildConfig
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.RootInsetsController
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
@@ -52,17 +53,28 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.DownloadsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.HistoryFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.ImportSubscriptionsFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryArtistsFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LibraryVideosFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||||
@@ -76,6 +88,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.St
|
|||||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||||
@@ -97,6 +110,7 @@ import com.futo.platformplayer.stores.StringStorage
|
|||||||
import com.futo.platformplayer.stores.SubscriptionStorage
|
import com.futo.platformplayer.stores.SubscriptionStorage
|
||||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||||
import com.futo.platformplayer.views.ToastView
|
import com.futo.platformplayer.views.ToastView
|
||||||
|
import com.futo.platformplayer.views.notification.NotificationOverlayView
|
||||||
import com.futo.polycentric.core.ApiMethods
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
@@ -147,6 +161,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
|
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
|
||||||
lateinit var _fragTopBarImport: ImportTopBarFragment;
|
lateinit var _fragTopBarImport: ImportTopBarFragment;
|
||||||
lateinit var _fragTopBarAdd: AddTopBarFragment;
|
lateinit var _fragTopBarAdd: AddTopBarFragment;
|
||||||
|
lateinit var _fragTopBarFiles: FilesTopBarFragment;
|
||||||
|
|
||||||
//Frags BotBar
|
//Frags BotBar
|
||||||
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
|
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
|
||||||
@@ -179,6 +194,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
lateinit var _fragBuy: BuyFragment;
|
lateinit var _fragBuy: BuyFragment;
|
||||||
lateinit var _fragSubGroup: SubscriptionGroupFragment;
|
lateinit var _fragSubGroup: SubscriptionGroupFragment;
|
||||||
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
|
lateinit var _fragSubGroupList: SubscriptionGroupListFragment;
|
||||||
|
lateinit var _fragLibrary: LibraryFragment;
|
||||||
|
lateinit var _fragLibraryAlbums: LibraryAlbumsFragment;
|
||||||
|
lateinit var _fragLibraryAlbum: LibraryAlbumFragment;
|
||||||
|
lateinit var _fragLibraryArtists: LibraryArtistsFragment;
|
||||||
|
lateinit var _fragLibraryArtist: LibraryArtistFragment;
|
||||||
|
lateinit var _fragLibraryVideos: LibraryVideosFragment;
|
||||||
|
lateinit var _fragLibrarySearch: LibrarySearchFragment;
|
||||||
|
lateinit var _fragLibraryFiles: LibraryFilesFragment;
|
||||||
|
lateinit var _fragNotifications: NotificationOverlayView.Frag;
|
||||||
|
lateinit var _fragSettings: SettingsFragment;
|
||||||
|
lateinit var _fragDeveloper: DeveloperFragment;
|
||||||
|
lateinit var _fragLogin: LoginFragment;
|
||||||
|
|
||||||
lateinit var _fragBrowser: BrowserFragment;
|
lateinit var _fragBrowser: BrowserFragment;
|
||||||
|
|
||||||
@@ -187,7 +214,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
//State
|
//State
|
||||||
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
private val _queue: LinkedList<Pair<MainFragment, Any?>> = LinkedList();
|
||||||
lateinit var fragCurrent: MainFragment private set;
|
var fragCurrent: MainFragment? = null; private set;
|
||||||
private var _parameterCurrent: Any? = null;
|
private var _parameterCurrent: Any? = null;
|
||||||
|
|
||||||
var fragBeforeOverlay: MainFragment? = null; private set;
|
var fragBeforeOverlay: MainFragment? = null; private set;
|
||||||
@@ -199,6 +226,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
private var _privateModeEnabled = false
|
private var _privateModeEnabled = false
|
||||||
private var _pictureInPictureEnabled = false
|
private var _pictureInPictureEnabled = false
|
||||||
private var _isFullscreen = false
|
private var _isFullscreen = false
|
||||||
|
private lateinit var _rootInsetsController: RootInsetsController
|
||||||
|
|
||||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
@@ -274,6 +302,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
@UnstableApi
|
@UnstableApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Logger.w(TAG, "MainActivity Starting [$mainId]");
|
Logger.w(TAG, "MainActivity Starting [$mainId]");
|
||||||
|
|
||||||
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
|
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
|
||||||
StateApp.instance.mainAppStarting(this);
|
StateApp.instance.mainAppStarting(this);
|
||||||
|
|
||||||
@@ -284,9 +313,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
setContentView(R.layout.activity_main);
|
setContentView(R.layout.activity_main);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
|
||||||
window.attributes.layoutInDisplayCutoutMode =
|
|
||||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
try {
|
try {
|
||||||
@@ -296,11 +322,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
window.isNavigationBarContrastEnforced = false
|
||||||
|
}
|
||||||
|
|
||||||
//Preload common files to memory
|
//Preload common files to memory
|
||||||
FragmentedStorage.get<SubscriptionStorage>();
|
FragmentedStorage.get<SubscriptionStorage>();
|
||||||
FragmentedStorage.get<Settings>();
|
FragmentedStorage.get<Settings>();
|
||||||
|
|
||||||
rootView = findViewById(R.id.rootView);
|
rootView = findViewById(R.id.rootView);
|
||||||
|
_rootInsetsController = RootInsetsController.attach(this, rootView)
|
||||||
|
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
|
||||||
|
|
||||||
_fragContainerTopBar = findViewById(R.id.fragment_top_bar);
|
_fragContainerTopBar = findViewById(R.id.fragment_top_bar);
|
||||||
_fragContainerMain = findViewById(R.id.fragment_main);
|
_fragContainerMain = findViewById(R.id.fragment_main);
|
||||||
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
|
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
|
||||||
@@ -317,6 +350,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
|
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
|
||||||
_fragTopBarImport = ImportTopBarFragment.newInstance();
|
_fragTopBarImport = ImportTopBarFragment.newInstance();
|
||||||
_fragTopBarAdd = AddTopBarFragment.newInstance();
|
_fragTopBarAdd = AddTopBarFragment.newInstance();
|
||||||
|
_fragTopBarFiles = FilesTopBarFragment.newInstance();
|
||||||
|
|
||||||
//BotBars
|
//BotBars
|
||||||
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
|
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
|
||||||
@@ -349,6 +383,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragBuy = BuyFragment.newInstance();
|
_fragBuy = BuyFragment.newInstance();
|
||||||
_fragSubGroup = SubscriptionGroupFragment.newInstance();
|
_fragSubGroup = SubscriptionGroupFragment.newInstance();
|
||||||
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
|
_fragSubGroupList = SubscriptionGroupListFragment.newInstance();
|
||||||
|
_fragLibrary = LibraryFragment.newInstance();
|
||||||
|
_fragLibraryAlbums = LibraryAlbumsFragment.newInstance();
|
||||||
|
_fragLibraryAlbum = LibraryAlbumFragment.newInstance();
|
||||||
|
_fragLibraryArtists = LibraryArtistsFragment.newInstance();
|
||||||
|
_fragLibraryArtist = LibraryArtistFragment.newInstance();
|
||||||
|
_fragLibraryVideos = LibraryVideosFragment.newInstance();
|
||||||
|
_fragLibraryFiles = LibraryFilesFragment.newInstance();
|
||||||
|
_fragLibrarySearch = LibrarySearchFragment.newInstance();
|
||||||
|
_fragNotifications = NotificationOverlayView.Frag();
|
||||||
|
_fragSettings = SettingsFragment.newInstance();
|
||||||
|
_fragDeveloper = DeveloperFragment.newInstance();
|
||||||
|
_fragLogin = LoginFragment.newInstance();
|
||||||
|
|
||||||
_fragBrowser = BrowserFragment.newInstance();
|
_fragBrowser = BrowserFragment.newInstance();
|
||||||
|
|
||||||
@@ -367,12 +413,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
updateSegmentPaddings();
|
updateSegmentPaddings();
|
||||||
};
|
};
|
||||||
_fragVideoDetail.onTransitioning.subscribe {
|
_fragVideoDetail.onTransitioning.subscribe {
|
||||||
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED)
|
if (it || _fragVideoDetail.state != VideoDetailFragment.State.MINIMIZED) {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation higher");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15f, resources.displayMetrics);
|
||||||
else
|
}
|
||||||
|
else {
|
||||||
|
Logger.i(TAG, "onTransition Setting elevation lower");
|
||||||
_fragContainerOverlay.elevation =
|
_fragContainerOverlay.elevation =
|
||||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_fragVideoDetail.onCloseEvent.subscribe {
|
_fragVideoDetail.onCloseEvent.subscribe {
|
||||||
@@ -411,6 +462,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||||
_isFullscreen = it
|
_isFullscreen = it
|
||||||
updatePrivateModeVisibility()
|
updatePrivateModeVisibility()
|
||||||
|
if (it) {
|
||||||
|
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||||
|
} else {
|
||||||
|
_rootInsetsController.exitFullscreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_fragVideoDetail.onMinimize.subscribe {
|
_fragVideoDetail.onMinimize.subscribe {
|
||||||
@@ -475,6 +531,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_fragImportSubscriptions.topBar = _fragTopBarImport;
|
_fragImportSubscriptions.topBar = _fragTopBarImport;
|
||||||
_fragImportPlaylists.topBar = _fragTopBarImport;
|
_fragImportPlaylists.topBar = _fragTopBarImport;
|
||||||
_fragSubGroupList.topBar = _fragTopBarAdd;
|
_fragSubGroupList.topBar = _fragTopBarAdd;
|
||||||
|
_fragLibrary.topBar = _fragTopBarGeneral;
|
||||||
|
_fragLibraryAlbums.topBar = _fragTopBarNavigation;
|
||||||
|
_fragLibraryAlbum.topBar = _fragTopBarNavigation;
|
||||||
|
_fragLibraryArtists.topBar = _fragTopBarNavigation;
|
||||||
|
_fragLibraryArtist.topBar = _fragTopBarNavigation;
|
||||||
|
_fragLibraryVideos.topBar = _fragTopBarNavigation;
|
||||||
|
_fragLibraryFiles.topBar = _fragTopBarFiles;
|
||||||
|
_fragLibrarySearch.topBar = _fragTopBarSearch;
|
||||||
|
_fragSettings.topBar = _fragTopBarNavigation;
|
||||||
|
_fragDeveloper.topBar = _fragTopBarNavigation;
|
||||||
|
_fragNotifications.topBar = _fragTopBarGeneral;
|
||||||
|
|
||||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||||
|
|
||||||
@@ -500,7 +567,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
defaultTab.action(_fragBotBarMenu);
|
defaultTab.action(_fragBotBarMenu);
|
||||||
StateSubscriptions.instance;
|
StateSubscriptions.instance;
|
||||||
|
|
||||||
fragCurrent.onShown(null, false);
|
fragCurrent?.onShown(null, false);
|
||||||
|
|
||||||
//Other stuff
|
//Other stuff
|
||||||
rootView.progress = 0f;
|
rootView.progress = 0f;
|
||||||
@@ -555,6 +622,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
sharedPreferences.edit().putBoolean("IsFirstBoot", false).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Settings.instance.autoUpdate.isAutoUpdateEnabled() && Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
|
requestNotificationPermissions("You have enabled background updating.\n\nGrayjay uses notifications to inform you when a new app update is available.");
|
||||||
|
}
|
||||||
|
|
||||||
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
val submissionStatus = FragmentedStorage.get<StringStorage>("subscriptionSubmissionStatus")
|
||||||
|
|
||||||
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
val numSubscriptions = StateSubscriptions.instance.getSubscriptionCount()
|
||||||
@@ -639,6 +710,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
_rootInsetsController.onConfigurationChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun showUrlQrCodeScanner() {
|
fun showUrlQrCodeScanner() {
|
||||||
try {
|
try {
|
||||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||||
@@ -697,17 +773,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
_wasStopped = true;
|
_wasStopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
handleIntent(intent);
|
handleIntent(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent?) {
|
private fun handleIntent(intent: Intent) {
|
||||||
if (intent == null)
|
|
||||||
return;
|
|
||||||
Logger.i(TAG, "handleIntent started by " + intent.action);
|
Logger.i(TAG, "handleIntent started by " + intent.action);
|
||||||
|
|
||||||
|
|
||||||
var targetData: String? = null;
|
var targetData: String? = null;
|
||||||
|
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
@@ -1086,7 +1158,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
if (_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED && _fragVideoDetail.onBackPressed())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!fragCurrent.onBackPressed())
|
if (!(fragCurrent?.onBackPressed() ?: true))
|
||||||
closeSegment();
|
closeSegment();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,6 +1209,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : Fragment> navigate(parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||||
|
val segment = getFragment<T>();
|
||||||
|
navigate(segment as MainFragment, parameter, withHistory, isBack);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
* 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
|
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||||
@@ -1159,27 +1236,27 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fragCurrent.onHide();
|
fragCurrent?.onHide();
|
||||||
|
|
||||||
if (segment.isMainView) {
|
if (segment.isMainView) {
|
||||||
var transaction = supportFragmentManager.beginTransaction();
|
var transaction = supportFragmentManager.beginTransaction();
|
||||||
if (segment.topBar != null) {
|
if (segment.topBar != null) {
|
||||||
if (segment.topBar != fragCurrent.topBar) {
|
if (segment.topBar != fragCurrent?.topBar) {
|
||||||
transaction = transaction
|
transaction = transaction
|
||||||
.show(segment.topBar as Fragment)
|
.show(segment.topBar as Fragment)
|
||||||
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
.replace(R.id.fragment_top_bar, segment.topBar as Fragment);
|
||||||
fragCurrent.topBar?.onHide();
|
fragCurrent?.topBar?.onHide();
|
||||||
}
|
}
|
||||||
} else if (fragCurrent.topBar != null)
|
} else if (fragCurrent?.topBar != null)
|
||||||
transaction.hide(fragCurrent.topBar as Fragment);
|
transaction.hide(fragCurrent?.topBar as Fragment);
|
||||||
|
|
||||||
transaction = transaction.replace(R.id.fragment_main, segment);
|
transaction = transaction.replace(R.id.fragment_main, segment);
|
||||||
|
|
||||||
if (segment.hasBottomBar) {
|
if (segment.hasBottomBar) {
|
||||||
if (!fragCurrent.hasBottomBar)
|
if (!(fragCurrent?.hasBottomBar ?: false))
|
||||||
transaction = transaction.show(_fragBotBarMenu);
|
transaction = transaction.show(_fragBotBarMenu);
|
||||||
} else {
|
} else {
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
transaction = transaction.hide(_fragBotBarMenu);
|
transaction = transaction.hide(_fragBotBarMenu);
|
||||||
}
|
}
|
||||||
transaction.commitNow();
|
transaction.commitNow();
|
||||||
@@ -1192,10 +1269,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fragCurrent.isHistory && withHistory && _queue.lastOrNull() != fragCurrent)
|
if (fragCurrent?.isHistory ?: false && withHistory && _queue.lastOrNull() != fragCurrent)
|
||||||
_queue.add(Pair(fragCurrent, _parameterCurrent));
|
_queue.add(Pair(fragCurrent!!, _parameterCurrent));
|
||||||
|
|
||||||
if (segment.isOverlay && !fragCurrent.isOverlay && withHistory)// && fragCurrent.isHistory)
|
if (segment.isOverlay && !(fragCurrent?.isOverlay ?: false) && withHistory)// && fragCurrent.isHistory)
|
||||||
fragBeforeOverlay = fragCurrent;
|
fragBeforeOverlay = fragCurrent;
|
||||||
|
|
||||||
fragCurrent = segment;
|
fragCurrent = segment;
|
||||||
@@ -1226,11 +1303,24 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
navigate(last.first, last.second, false, true);
|
navigate(last.first, last.second, false, true);
|
||||||
} else {
|
} else {
|
||||||
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||||
|
Logger.i(TAG, "Closing activity because _fragVideoDetail.state == closed");
|
||||||
finish();
|
finish();
|
||||||
} else {
|
} else {
|
||||||
|
//UIDialogs.toast("Grayjay continues in background because of an open video.")
|
||||||
|
if(Settings.instance.playback.isBackgroundPictureInPicture()) {
|
||||||
|
try {
|
||||||
|
_fragVideoDetail._viewDetail?.startPictureInPicture();
|
||||||
|
_fragVideoDetail?.forcePictureInPicture();
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
} //Fail silently
|
||||||
|
}
|
||||||
|
else
|
||||||
|
moveTaskToBack(false);
|
||||||
|
/*
|
||||||
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
|
||||||
finish();
|
finish();
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1249,6 +1339,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
VideoDetailFragment::class -> _fragVideoDetail as T;
|
VideoDetailFragment::class -> _fragVideoDetail as T;
|
||||||
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
|
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
|
||||||
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
||||||
|
FilesTopBarFragment::class -> _fragTopBarFiles as T;
|
||||||
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
||||||
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
||||||
CommentsFragment::class -> _fragMainComments as T;
|
CommentsFragment::class -> _fragMainComments as T;
|
||||||
@@ -1273,6 +1364,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
BuyFragment::class -> _fragBuy as T;
|
BuyFragment::class -> _fragBuy as T;
|
||||||
SubscriptionGroupFragment::class -> _fragSubGroup as T;
|
SubscriptionGroupFragment::class -> _fragSubGroup as T;
|
||||||
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
|
SubscriptionGroupListFragment::class -> _fragSubGroupList as T;
|
||||||
|
LibraryFragment::class -> _fragLibrary as T;
|
||||||
|
LibraryAlbumsFragment::class -> _fragLibraryAlbums as T;
|
||||||
|
LibraryAlbumFragment::class -> _fragLibraryAlbum as T;
|
||||||
|
LibraryArtistsFragment::class -> _fragLibraryArtists as T;
|
||||||
|
LibraryArtistFragment::class -> _fragLibraryArtist as T;
|
||||||
|
LibraryVideosFragment::class -> _fragLibraryVideos as T;
|
||||||
|
LibraryFilesFragment::class -> _fragLibraryFiles as T;
|
||||||
|
LibrarySearchFragment::class -> _fragLibrarySearch as T;
|
||||||
|
NotificationOverlayView.Frag::class -> _fragNotifications as T;
|
||||||
|
SettingsFragment:: class -> _fragSettings as T;
|
||||||
|
DeveloperFragment::class -> _fragDeveloper as T;
|
||||||
|
LoginFragment::class -> _fragLogin as T;
|
||||||
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1280,7 +1383,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
|
|
||||||
private fun updateSegmentPaddings() {
|
private fun updateSegmentPaddings() {
|
||||||
var paddingBottom = 0f;
|
var paddingBottom = 0f;
|
||||||
if (fragCurrent.hasBottomBar)
|
if (fragCurrent?.hasBottomBar ?: false)
|
||||||
paddingBottom += HEIGHT_MENU_DP;
|
paddingBottom += HEIGHT_MENU_DP;
|
||||||
|
|
||||||
_fragContainerOverlay.setPadding(
|
_fragContainerOverlay.setPadding(
|
||||||
@@ -1297,6 +1400,23 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _callbackPermissionAudio: ((Boolean)->Unit)? = null;
|
||||||
|
var _callbackPermissionVideo: ((Boolean)->Unit)? = null;
|
||||||
|
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||||
|
_callbackPermissionAudio?.invoke(isGranted);
|
||||||
|
});
|
||||||
|
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
|
||||||
|
_callbackPermissionVideo?.invoke(isGranted);
|
||||||
|
});
|
||||||
|
fun requestPermissionAudio(cb: ((Boolean)->Unit)? = null) {
|
||||||
|
_callbackPermissionAudio = cb;
|
||||||
|
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
|
||||||
|
}
|
||||||
|
fun requestPermissionVideo(cb: ((Boolean)->Unit)? = null) {
|
||||||
|
_callbackPermissionVideo = cb;
|
||||||
|
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||||
@@ -1423,4 +1543,4 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||||||
return sourcesIntent;
|
return sourcesIntent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import android.view.View
|
|||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.SignedEvent
|
import com.futo.polycentric.core.SignedEvent
|
||||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||||
@@ -29,8 +32,10 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
|||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.toBase64Url
|
import com.futo.polycentric.core.toBase64Url
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
import com.google.zxing.MultiFormatWriter
|
import com.google.zxing.MultiFormatWriter
|
||||||
import com.google.zxing.common.BitMatrix
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
|||||||
class PolycentricBackupActivity : AppCompatActivity() {
|
class PolycentricBackupActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonShare: BigButton;
|
private lateinit var _buttonShare: BigButton;
|
||||||
private lateinit var _buttonCopy: BigButton;
|
private lateinit var _buttonCopy: BigButton;
|
||||||
|
private lateinit var _buttonExportFile: BigButton;
|
||||||
private lateinit var _imageQR: ImageView;
|
private lateinit var _imageQR: ImageView;
|
||||||
private lateinit var _exportBundle: String;
|
private lateinit var _exportBundle: String;
|
||||||
private lateinit var _textQR: TextView;
|
private lateinit var _textQR: TextView;
|
||||||
|
private lateinit var _textQRHint: TextView;
|
||||||
private lateinit var _loader: View
|
private lateinit var _loader: View
|
||||||
|
|
||||||
|
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
|
uri?.let { fileUri ->
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||||
|
outputStream.write(_exportBundle.toByteArray())
|
||||||
|
}
|
||||||
|
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to write to document", e)
|
||||||
|
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
}
|
}
|
||||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_buttonShare = findViewById(R.id.button_share)
|
_buttonShare = findViewById(R.id.button_share)
|
||||||
_buttonCopy = findViewById(R.id.button_copy)
|
_buttonCopy = findViewById(R.id.button_copy)
|
||||||
|
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||||
_imageQR = findViewById(R.id.image_qr)
|
_imageQR = findViewById(R.id.image_qr)
|
||||||
_textQR = findViewById(R.id.text_qr)
|
_textQR = findViewById(R.id.text_qr)
|
||||||
|
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||||
_loader = findViewById(R.id.progress_loader)
|
_loader = findViewById(R.id.progress_loader)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
finish();
|
finish();
|
||||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_imageQR.visibility = View.INVISIBLE
|
_imageQR.visibility = View.INVISIBLE
|
||||||
_textQR.visibility = View.INVISIBLE
|
_textQR.visibility = View.INVISIBLE
|
||||||
|
_textQRHint.visibility = View.INVISIBLE
|
||||||
_loader.visibility = View.VISIBLE
|
_loader.visibility = View.VISIBLE
|
||||||
_buttonShare.visibility = View.INVISIBLE
|
_buttonShare.visibility = View.INVISIBLE
|
||||||
_buttonCopy.visibility = View.INVISIBLE
|
_buttonCopy.visibility = View.INVISIBLE
|
||||||
|
_buttonExportFile.visibility = View.INVISIBLE
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||||
|
_exportBundle = bundle
|
||||||
|
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val pair = withContext(Dispatchers.IO) {
|
val pair = withContext(Dispatchers.IO) {
|
||||||
val bundle = createExportBundle()
|
if (!isContentSuitableForQRCode(bundle)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
val dimension = TypedValue.applyDimension(
|
val dimension = TypedValue.applyDimension(
|
||||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||||
).toInt()
|
).toInt()
|
||||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
Pair(bundle, qr)
|
Pair(bundle, qr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_exportBundle = pair.first
|
|
||||||
_imageQR.setImageBitmap(pair.second)
|
_imageQR.setImageBitmap(pair.second)
|
||||||
_imageQR.visibility = View.VISIBLE
|
_imageQR.visibility = View.VISIBLE
|
||||||
_textQR.visibility = View.VISIBLE
|
_textQR.visibility = View.VISIBLE
|
||||||
|
_textQRHint.visibility = View.VISIBLE
|
||||||
_buttonShare.visibility = View.VISIBLE
|
_buttonShare.visibility = View.VISIBLE
|
||||||
_buttonCopy.visibility = View.VISIBLE
|
_buttonCopy.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
_imageQR.setOnClickListener {
|
||||||
|
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
|
||||||
|
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
|
||||||
|
|
||||||
|
if (e.message?.contains("Data too big") == true) {
|
||||||
|
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
|
||||||
|
_buttonExportFile.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
_textQR.text = getString(R.string.failed_to_generate_qr_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_textQR.visibility = View.VISIBLE
|
||||||
|
_textQRHint.visibility = View.INVISIBLE
|
||||||
|
_buttonShare.visibility = View.VISIBLE
|
||||||
|
_buttonCopy.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Hide QR image since generation failed
|
||||||
_imageQR.visibility = View.INVISIBLE
|
_imageQR.visibility = View.INVISIBLE
|
||||||
_textQR.visibility = View.INVISIBLE
|
|
||||||
_buttonShare.visibility = View.INVISIBLE
|
|
||||||
_buttonCopy.visibility = View.INVISIBLE
|
|
||||||
} finally {
|
} finally {
|
||||||
_loader.visibility = View.GONE
|
_loader.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||||
clipboard.setPrimaryClip(clip);
|
clipboard.setPrimaryClip(clip);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_buttonExportFile.onClick.subscribe {
|
||||||
|
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
|
||||||
|
_createDocumentLauncher.launch(fileName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||||
|
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||||
|
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
if (!isContentSuitableForQRCode(content)) {
|
||||||
return bitMatrixToBitmap(bitMatrix);
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||||
|
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||||
|
hints[EncodeHintType.MARGIN] = 1
|
||||||
|
|
||||||
|
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||||
|
return bitMatrixToBitmap(bitMatrix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
.setBody(exportBundle.toByteString())
|
.setBody(exportBundle.toByteString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
val data = urlInfo.toByteArray()
|
||||||
|
return "polycentric://" + data.toBase64Url()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
|||||||
import userpackage.Protocol.ExportBundle
|
import userpackage.Protocol.ExportBundle
|
||||||
|
|
||||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton
|
||||||
private lateinit var _buttonScanProfile: LinearLayout;
|
private lateinit var _buttonScanProfile: LinearLayout
|
||||||
private lateinit var _buttonImportProfile: LinearLayout;
|
private lateinit var _buttonImportFile: LinearLayout
|
||||||
private lateinit var _editProfile: EditText;
|
private lateinit var _buttonImportProfile: LinearLayout
|
||||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
private lateinit var _editProfile: EditText
|
||||||
|
private lateinit var _loaderOverlay: LoaderOverlay
|
||||||
|
|
||||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val _qrCodeResultLauncher =
|
||||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
scanResult?.let {
|
val scanResult =
|
||||||
if (it.contents != null) {
|
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
val scannedUrl = it.contents
|
scanResult?.let {
|
||||||
import(scannedUrl)
|
if (it.contents != null) {
|
||||||
|
val scannedUrl = it.contents
|
||||||
|
import(scannedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _filePickerLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
uri?.let { fileUri ->
|
||||||
|
try {
|
||||||
|
// Check file size before reading
|
||||||
|
val fileSize =
|
||||||
|
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
|
||||||
|
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
|
||||||
|
|
||||||
|
if (fileSize > maxFileSize) {
|
||||||
|
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSize == 0L) {
|
||||||
|
UIDialogs.toast(this, "Selected file is empty.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
val content =
|
||||||
|
contentResolver
|
||||||
|
.openInputStream(fileUri)
|
||||||
|
?.bufferedReader()
|
||||||
|
?.readText()
|
||||||
|
content?.let { fileContent ->
|
||||||
|
val trimmedContent = fileContent.trim()
|
||||||
|
|
||||||
|
// Check if content is empty after trimming
|
||||||
|
if (trimmedContent.isEmpty()) {
|
||||||
|
UIDialogs.toast(this, "Selected file contains no data.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content looks like a valid polycentric URL
|
||||||
|
if (!trimmedContent.startsWith("polycentric://")) {
|
||||||
|
UIDialogs.toast(
|
||||||
|
this,
|
||||||
|
"Selected file does not contain a valid polycentric profile URL."
|
||||||
|
)
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
import(trimmedContent)
|
||||||
|
}
|
||||||
|
?: run { UIDialogs.toast(this, "Could not read file content.") }
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Logger.e(TAG, "Security exception reading file", e)
|
||||||
|
UIDialogs.toast(this, "Permission denied to read file.")
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Logger.e(TAG, "Out of memory reading file", e)
|
||||||
|
UIDialogs.toast(this, "File too large to process.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to read file", e)
|
||||||
|
UIDialogs.toast(this, "Failed to read file: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_polycentric_import_profile);
|
setContentView(R.layout.activity_polycentric_import_profile)
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
_buttonHelp = findViewById(R.id.button_help);
|
_buttonHelp = findViewById(R.id.button_help)
|
||||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||||
_editProfile = findViewById(R.id.edit_profile);
|
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
_editProfile = findViewById(R.id.edit_profile)
|
||||||
finish();
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||||
};
|
|
||||||
|
|
||||||
_buttonHelp.setOnClickListener {
|
_buttonHelp.setOnClickListener {
|
||||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||||
};
|
}
|
||||||
|
|
||||||
_buttonScanProfile.setOnClickListener {
|
_buttonScanProfile.setOnClickListener {
|
||||||
val integrator = IntentIntegrator(this)
|
val integrator = IntentIntegrator(this)
|
||||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||||
integrator.setOrientationLocked(true);
|
integrator.setOrientationLocked(true)
|
||||||
integrator.setCameraId(0)
|
integrator.setCameraId(0)
|
||||||
integrator.setBeepEnabled(false)
|
integrator.setBeepEnabled(false)
|
||||||
integrator.setBarcodeImageEnabled(true)
|
integrator.setBarcodeImageEnabled(true)
|
||||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||||
};
|
}
|
||||||
|
|
||||||
|
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||||
|
|
||||||
_buttonImportProfile.setOnClickListener {
|
_buttonImportProfile.setOnClickListener {
|
||||||
if (_editProfile.text.isEmpty()) {
|
if (_editProfile.text.isEmpty()) {
|
||||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||||
return@setOnClickListener;
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
import(_editProfile.text.toString());
|
import(_editProfile.text.toString())
|
||||||
};
|
}
|
||||||
|
|
||||||
val url = intent.getStringExtra("url");
|
val url = intent.getStringExtra("url")
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
import(url);
|
import(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun import(url: String) {
|
private fun import(url: String) {
|
||||||
if (!url.startsWith("polycentric://")) {
|
if (!url.startsWith("polycentric://")) {
|
||||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_loaderOverlay.show()
|
_loaderOverlay.show()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||||
|
|
||||||
if (urlInfo.urlType != 3L) {
|
if (urlInfo.urlType != 3L) {
|
||||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||||
|
|
||||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||||
if (existingProcessSecret != null) {
|
if (existingProcessSecret != null) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
UIDialogs.toast(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
getString(R.string.this_profile_is_already_imported)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return@launch;
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||||
Store.instance.addProcessSecret(processSecret);
|
Store.instance.addProcessSecret(processSecret)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
|||||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
val processHandle = processSecret.toProcessHandle();
|
val processHandle = processSecret.toProcessHandle()
|
||||||
|
|
||||||
for (e in exportBundle.events.eventsList) {
|
for (e in exportBundle.events.eventsList) {
|
||||||
try {
|
try {
|
||||||
val se = SignedEvent.fromProto(e);
|
val se = SignedEvent.fromProto(e)
|
||||||
Store.instance.putSignedEvent(se);
|
Store.instance.putSignedEvent(se)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Ignored invalid event", e);
|
Logger.w(TAG, "Ignored invalid event", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
startActivity(
|
||||||
finish();
|
Intent(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
PolycentricProfileActivity::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to import profile", e);
|
Logger.w(TAG, "Failed to import profile", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
UIDialogs.toast(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
getString(R.string.failed_to_import_profile) + " '${e.message}'"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||||
_loaderOverlay.hide();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PolycentricImportProfileActivity";
|
private const val TAG = "PolycentricImportProfileActivity"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.SeekBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.polycentric.ModerationsManager
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
|
||||||
|
class PolycentricModerationActivity : AppCompatActivity() {
|
||||||
|
private lateinit var _seekbarOffensive: SeekBar
|
||||||
|
private lateinit var _seekbarExplicit: SeekBar
|
||||||
|
private lateinit var _seekbarViolence: SeekBar
|
||||||
|
private lateinit var _textOffensiveDesc: TextView
|
||||||
|
private lateinit var _textExplicitDesc: TextView
|
||||||
|
private lateinit var _textViolenceDesc: TextView
|
||||||
|
private lateinit var _textOffensiveValue: TextView
|
||||||
|
private lateinit var _textExplicitValue: TextView
|
||||||
|
private lateinit var _textViolenceValue: TextView
|
||||||
|
private lateinit var _moderationsManager: ModerationsManager
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_polycentric_moderation)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
_moderationsManager = ModerationsManager.getInstance()
|
||||||
|
try {
|
||||||
|
_moderationsManager = ModerationsManager.getInstance()
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
|
||||||
|
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
|
||||||
|
_seekbarViolence = findViewById(R.id.seekbar_violence)
|
||||||
|
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
|
||||||
|
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
|
||||||
|
_textViolenceDesc = findViewById(R.id.text_violence_desc)
|
||||||
|
_textOffensiveValue = findViewById(R.id.text_offensive_value)
|
||||||
|
_textExplicitValue = findViewById(R.id.text_explicit_value)
|
||||||
|
_textViolenceValue = findViewById(R.id.text_violence_value)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettings() {
|
||||||
|
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
|
||||||
|
|
||||||
|
val offensiveLevel = levels["hate"] ?: 2
|
||||||
|
val explicitLevel = levels["sexual"] ?: 1
|
||||||
|
val violenceLevel = levels["violence"] ?: 1
|
||||||
|
|
||||||
|
_seekbarOffensive.progress = offensiveLevel
|
||||||
|
_seekbarExplicit.progress = explicitLevel
|
||||||
|
_seekbarViolence.progress = violenceLevel
|
||||||
|
|
||||||
|
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||||
|
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||||
|
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("hate", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("sexual", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("violence", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
|
||||||
|
val progress = seekBar?.progress ?: 0
|
||||||
|
textDesc.text = descriptions[progress]
|
||||||
|
textValue.text = progress.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOffensiveDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"Neutral, general terms, no bias or hate.",
|
||||||
|
"Mildly sensitive, factual.",
|
||||||
|
"Potentially offensive content",
|
||||||
|
"Offensive content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExplicitDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"No explicit content",
|
||||||
|
"Mildly suggestive, factual or educational",
|
||||||
|
"Moderate sexual content, non-graphic",
|
||||||
|
"Explicit sexual content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getViolenceDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"Non-violent",
|
||||||
|
"Mild violence, factual or contextual",
|
||||||
|
"Moderate violence, some graphic content.",
|
||||||
|
"Graphic violence"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton;
|
||||||
private lateinit var _editName: EditText;
|
private lateinit var _editName: EditText;
|
||||||
private lateinit var _buttonExport: BigButton;
|
private lateinit var _buttonExport: BigButton;
|
||||||
private lateinit var _buttonOpenHarborProfile: BigButton;
|
private lateinit var _buttonModeration: BigButton;
|
||||||
private lateinit var _buttonLogout: BigButton;
|
private lateinit var _buttonLogout: BigButton;
|
||||||
private lateinit var _buttonDelete: BigButton;
|
private lateinit var _buttonDelete: BigButton;
|
||||||
private lateinit var _username: String;
|
private lateinit var _username: String;
|
||||||
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||||
_editName = findViewById(R.id.edit_profile_name);
|
_editName = findViewById(R.id.edit_profile_name);
|
||||||
_buttonExport = findViewById(R.id.button_export);
|
_buttonExport = findViewById(R.id.button_export);
|
||||||
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
|
_buttonModeration = findViewById(R.id.button_moderation);
|
||||||
_buttonLogout = findViewById(R.id.button_logout);
|
_buttonLogout = findViewById(R.id.button_logout);
|
||||||
_buttonDelete = findViewById(R.id.button_delete);
|
_buttonDelete = findViewById(R.id.button_delete);
|
||||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||||
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||||
};
|
};
|
||||||
|
|
||||||
_buttonOpenHarborProfile.onClick.subscribe {
|
_buttonModeration.onClick.subscribe {
|
||||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
startActivity(Intent(this, PolycentricModerationActivity::class.java));
|
||||||
processHandle?.let {
|
};
|
||||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
|
|
||||||
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
|
|
||||||
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
|
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_buttonLogout.onClick.subscribe {
|
_buttonLogout.onClick.subscribe {
|
||||||
StatePolycentric.instance.setProcessHandle(null);
|
StatePolycentric.instance.setProcessHandle(null);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.MultiFormatWriter
|
||||||
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
|
||||||
|
class QRCodeFullscreenActivity : AppCompatActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_QR_TEXT = "qr_text"
|
||||||
|
|
||||||
|
fun createIntent(context: Context, qrText: String): android.content.Intent {
|
||||||
|
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_QR_TEXT, qrText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_qr_code_fullscreen)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
|
||||||
|
|
||||||
|
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
|
||||||
|
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
|
||||||
|
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
|
||||||
|
|
||||||
|
// Generate QR code bitmap from text
|
||||||
|
qrText?.let { text ->
|
||||||
|
try {
|
||||||
|
if (!isContentSuitableForQRCode(text)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val dimension = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
|
||||||
|
).toInt()
|
||||||
|
val qrBitmap = generateQRCode(text, dimension, dimension)
|
||||||
|
imageQR.setImageBitmap(qrBitmap)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If QR generation fails, show error or fallback
|
||||||
|
imageQR.setImageResource(R.drawable.ic_qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonBack.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonClose.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
imageQR.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||||
|
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||||
|
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
|
if (!isContentSuitableForQRCode(content)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||||
|
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||||
|
hints[EncodeHintType.MARGIN] = 1
|
||||||
|
|
||||||
|
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||||
|
return bitMatrixToBitmap(bitMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
|
val width = matrix.width
|
||||||
|
val height = matrix.height
|
||||||
|
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||||
|
|
||||||
|
for (x in 0 until width) {
|
||||||
|
for (y in 0 until height) {
|
||||||
|
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
package com.futo.platformplayer.activities
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.activity.result.ActivityResult
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.futo.platformplayer.*
|
|
||||||
import com.futo.platformplayer.constructs.Event0
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.views.LoaderView
|
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
|
||||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|
||||||
private lateinit var _form: FieldForm;
|
|
||||||
private lateinit var _buttonBack: ImageButton;
|
|
||||||
private lateinit var _loaderView: LoaderView;
|
|
||||||
|
|
||||||
private lateinit var _devSets: LinearLayout;
|
|
||||||
private lateinit var _buttonDev: MaterialButton;
|
|
||||||
|
|
||||||
private var _isFinished = false;
|
|
||||||
|
|
||||||
lateinit var overlay: FrameLayout;
|
|
||||||
|
|
||||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
|
||||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
|
||||||
if (isGranted)
|
|
||||||
UIDialogs.toast(this, "Notification permission granted");
|
|
||||||
else
|
|
||||||
UIDialogs.toast(this, "Notification permission denied");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
|
||||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
|
||||||
}
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.activity_settings);
|
|
||||||
setNavigationBarColorAndIcons();
|
|
||||||
|
|
||||||
_form = findViewById(R.id.settings_form);
|
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
|
||||||
_buttonDev = findViewById(R.id.button_dev);
|
|
||||||
_devSets = findViewById(R.id.dev_settings);
|
|
||||||
_loaderView = findViewById(R.id.loader);
|
|
||||||
overlay = findViewById(R.id.overlay_container);
|
|
||||||
|
|
||||||
_form.onChanged.subscribe { field, _ ->
|
|
||||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
|
||||||
_form.setObjectValues();
|
|
||||||
Settings.instance.save();
|
|
||||||
|
|
||||||
if(field.descriptor?.id == "app_language") {
|
|
||||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
|
||||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(field.descriptor?.id == "background_update") {
|
|
||||||
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
|
|
||||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
|
|
||||||
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
|
||||||
if(!notifManager.areNotificationsEnabled()) {
|
|
||||||
UIDialogs.toast(this, "Notifications aren't enabled");
|
|
||||||
|
|
||||||
when {
|
|
||||||
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
|
|
||||||
}
|
|
||||||
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
|
|
||||||
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
|
|
||||||
"Notifications need to be enabled for background updating to function", null, 0,
|
|
||||||
UIDialogs.Action("Cancel", {}),
|
|
||||||
UIDialogs.Action("Enable", {
|
|
||||||
requestPermissionLauncher.launch(notifPermission);
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
requestPermissionLauncher.launch(notifPermission);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
_buttonBack.setOnClickListener {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
_buttonDev.setOnClickListener {
|
|
||||||
startActivity(Intent(this, DeveloperActivity::class.java));
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastActivity = this;
|
|
||||||
|
|
||||||
reloadSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
var isFirstLoad = true;
|
|
||||||
fun reloadSettings() {
|
|
||||||
val firstLoad = isFirstLoad;
|
|
||||||
isFirstLoad = false;
|
|
||||||
_form.setSearchVisible(false);
|
|
||||||
_loaderView.start();
|
|
||||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
|
||||||
_loaderView.stop();
|
|
||||||
_form.setSearchVisible(true);
|
|
||||||
|
|
||||||
var devCounter = 0;
|
|
||||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
|
||||||
devCounter++;
|
|
||||||
if(devCounter > 5) {
|
|
||||||
devCounter = 0;
|
|
||||||
SettingsDev.instance.developerMode = true;
|
|
||||||
SettingsDev.instance.save();
|
|
||||||
updateDevMode();
|
|
||||||
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if(firstLoad) {
|
|
||||||
val query = intent.getStringExtra("query");
|
|
||||||
if(!query.isNullOrEmpty()) {
|
|
||||||
_form.setSearchQuery(query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
updateDevMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateDevMode() {
|
|
||||||
if(SettingsDev.instance.developerMode)
|
|
||||||
_devSets.visibility = View.VISIBLE;
|
|
||||||
else
|
|
||||||
_devSets.visibility = View.GONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
|
||||||
super.finish()
|
|
||||||
_isFinished = true;
|
|
||||||
if(_lastActivity == this)
|
|
||||||
_lastActivity = null;
|
|
||||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
|
||||||
private var requestCode: Int? = -1;
|
|
||||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
result: ActivityResult ->
|
|
||||||
val handler = synchronized(resultLauncherMap) {
|
|
||||||
resultLauncherMap.remove(requestCode);
|
|
||||||
}
|
|
||||||
if(handler != null)
|
|
||||||
handler(result);
|
|
||||||
};
|
|
||||||
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
|
|
||||||
synchronized(resultLauncherMap) {
|
|
||||||
resultLauncherMap[code] = handler;
|
|
||||||
}
|
|
||||||
requestCode = code;
|
|
||||||
resultLauncher.launch(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
settingsActivityClosed.emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
//TODO: Temporary for solving Settings issues
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private var _lastActivity: SettingsActivity? = null;
|
|
||||||
|
|
||||||
val settingsActivityClosed = Event0()
|
|
||||||
|
|
||||||
fun getActivity(): SettingsActivity? {
|
|
||||||
val act = _lastActivity;
|
|
||||||
if(act != null && !act._isFinished)
|
|
||||||
return act;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,7 +110,19 @@ class SyncPairActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
|
var wasCompleted = false
|
||||||
|
|
||||||
|
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
|
||||||
|
if (wasCompleted) {
|
||||||
|
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message} ignored because wasCompleted')")
|
||||||
|
return@connect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete == true) {
|
||||||
|
wasCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message}')")
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
if (complete != null) {
|
if (complete != null) {
|
||||||
if (complete) {
|
if (complete) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.time.Duration
|
|||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
open class ManagedHttpClient {
|
open class ManagedHttpClient {
|
||||||
@@ -89,10 +90,16 @@ open class ManagedHttpClient {
|
|||||||
return clonedClient;
|
return clonedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tryHead(url: String): Map<String, String>? {
|
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
|
||||||
|
if (modifier == null) return Pair(url, headers)
|
||||||
|
val modified = modifier.modifyRequest(url, headers)
|
||||||
|
return Pair(modified.url ?: url, modified.headers.toMutableMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
|
||||||
ensureNotMainThread()
|
ensureNotMainThread()
|
||||||
try {
|
try {
|
||||||
val result = head(url);
|
val result = head(url, HashMap(), modifier);
|
||||||
if(result.isOk)
|
if(result.isOk)
|
||||||
return result.getHeadersFlat();
|
return result.getHeadersFlat();
|
||||||
else
|
else
|
||||||
@@ -141,12 +148,14 @@ open class ManagedHttpClient {
|
|||||||
return Socket(websocket);
|
return Socket(websocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "GET", null, headers));
|
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||||
|
return execute(Request(finalUrl, "GET", null, finalHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "HEAD", null, headers));
|
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||||
|
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||||
|
|||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
package com.futo.platformplayer.api.http.server.handlers
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class HttpContentUriHandler(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val explicitContentType: String? = null
|
||||||
|
) : HttpHandler(method, path) {
|
||||||
|
|
||||||
|
override fun handle(httpContext: HttpContext) {
|
||||||
|
val resolver = contentResolver
|
||||||
|
val requestHeaders = httpContext.headers
|
||||||
|
val responseHeaders = this.headers.clone()
|
||||||
|
|
||||||
|
val meta = try {
|
||||||
|
queryMetadata(resolver, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||||
|
httpContext.respondCode(404, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentType = explicitContentType
|
||||||
|
?: resolver.getType(uri)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
responseHeaders["Content-Type"] = contentType
|
||||||
|
|
||||||
|
meta.lastModifiedMillis?.let { lastModified ->
|
||||||
|
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||||
|
|
||||||
|
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||||
|
if (ifModifiedSinceHeader != null) {
|
||||||
|
val ifModifiedSince = try {
|
||||||
|
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||||
|
httpContext.respondCode(304, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||||
|
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||||
|
|
||||||
|
val length = meta.size
|
||||||
|
if (length == null) {
|
||||||
|
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||||
|
responseHeaders.remove("Content-Length")
|
||||||
|
responseHeaders.remove("Content-Range")
|
||||||
|
responseHeaders.remove("Accept-Ranges")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = null,
|
||||||
|
length = null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseHeaders["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
val rangeHeader = requestHeaders["Range"]
|
||||||
|
if (rangeHeader.isNullOrBlank()) {
|
||||||
|
responseHeaders["Content-Length"] = length.toString()
|
||||||
|
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = 0L,
|
||||||
|
length = length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val range = parseRange(rangeHeader, length)
|
||||||
|
if (range == null) {
|
||||||
|
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||||
|
responseHeaders["Content-Range"] = "bytes */$length"
|
||||||
|
httpContext.respondCode(416, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = range.first
|
||||||
|
val endInclusive = range.last
|
||||||
|
val bytesToSend = endInclusive - start + 1
|
||||||
|
|
||||||
|
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||||
|
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||||
|
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 206,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = start,
|
||||||
|
length = bytesToSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContentMeta(
|
||||||
|
val displayName: String?,
|
||||||
|
val size: Long?,
|
||||||
|
val lastModifiedMillis: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||||
|
var displayName: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var lastModifiedMillis: Long? = null
|
||||||
|
|
||||||
|
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||||
|
displayName = cursor.getString(nameIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||||
|
val s = cursor.getLong(sizeIndex)
|
||||||
|
if (s >= 0) size = s // -1 means unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateModifiedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModifiedMillis == null) {
|
||||||
|
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
|
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateAddedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName == null) {
|
||||||
|
displayName = uri.lastPathSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size == null) {
|
||||||
|
try {
|
||||||
|
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||||
|
val assetLen = afd.length
|
||||||
|
if (assetLen >= 0) {
|
||||||
|
size = assetLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentMeta(
|
||||||
|
displayName = displayName,
|
||||||
|
size = size,
|
||||||
|
lastModifiedMillis = lastModifiedMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||||
|
if (totalLength <= 0L) return null
|
||||||
|
|
||||||
|
val prefix = "bytes="
|
||||||
|
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val spec = header.substring(prefix.length).trim()
|
||||||
|
if (spec.isEmpty()) return null
|
||||||
|
|
||||||
|
if (spec.contains(",")) return null
|
||||||
|
|
||||||
|
val dashIndex = spec.indexOf('-')
|
||||||
|
if (dashIndex < 0) return null
|
||||||
|
|
||||||
|
val startPart = spec.substring(0, dashIndex).trim()
|
||||||
|
val endPart = spec.substring(dashIndex + 1).trim()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
startPart.isNotEmpty() -> {
|
||||||
|
val start = startPart.toLongOrNull() ?: return null
|
||||||
|
if (start < 0 || start >= totalLength) return null
|
||||||
|
|
||||||
|
val end = if (endPart.isNotEmpty()) {
|
||||||
|
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||||
|
if (rawEnd < start) return null
|
||||||
|
rawEnd.coerceAtMost(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
totalLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
endPart.isNotEmpty() -> {
|
||||||
|
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||||
|
if (suffixLen <= 0L) return null
|
||||||
|
|
||||||
|
if (suffixLen >= totalLength) {
|
||||||
|
0L..(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
val start = totalLength - suffixLen
|
||||||
|
val end = totalLength - 1
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||||
|
try {
|
||||||
|
val input = resolver.openInputStream(uri)
|
||||||
|
if (input == null) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri")
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.use { inputStream ->
|
||||||
|
httpContext.respond(statusCode, headers) { outputStream ->
|
||||||
|
try {
|
||||||
|
val offset = start ?: 0L
|
||||||
|
if (offset > 0L) {
|
||||||
|
skipFully(inputStream, offset)
|
||||||
|
}
|
||||||
|
copyStream(inputStream, outputStream, length)
|
||||||
|
outputStream.flush()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri", e)
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||||
|
httpContext.respondCode(500, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
if (limit == null) {
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var remaining = limit
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||||
|
val read = input.read(buffer, 0, toRead)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
remaining -= read.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||||
|
var remaining = bytesToSkip
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val skipped = input.skip(remaining)
|
||||||
|
if (skipped <= 0L) {
|
||||||
|
val b = input.read()
|
||||||
|
if (b == -1) break
|
||||||
|
remaining -= 1L
|
||||||
|
} else {
|
||||||
|
remaining -= skipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HttpContentUriHandler"
|
||||||
|
|
||||||
|
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-9
@@ -5,6 +5,7 @@ import android.util.Log
|
|||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||||
import com.futo.platformplayer.readLine
|
import com.futo.platformplayer.readLine
|
||||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
private var _injectReferer = false;
|
private var _injectReferer = false;
|
||||||
|
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
|
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||||
|
|
||||||
override fun handle(context: HttpContext) {
|
override fun handle(context: HttpContext) {
|
||||||
if (useTcp) {
|
if (useTcp) {
|
||||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
for (injectHeader in _injectRequestHeader)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||||
|
|
||||||
val parsed = Uri.parse(targetUrl);
|
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||||
|
var url = targetUrl
|
||||||
|
if (req != null) {
|
||||||
|
req.url?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
req.headers.let {
|
||||||
|
proxyHeaders.clear()
|
||||||
|
proxyHeaders.putAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = Uri.parse(url);
|
||||||
if(_injectHost)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
|
||||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||||
|
|
||||||
val resp = when (useMethod) {
|
val resp = when (useMethod) {
|
||||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
"GET" -> _client.get(url, proxyHeaders);
|
||||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
"HEAD" -> _client.head(url, proxyHeaders)
|
||||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||||
};
|
};
|
||||||
|
|
||||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||||
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
for (injectHeader in _injectRequestHeader)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||||
|
|
||||||
val parsed = Uri.parse(targetUrl);
|
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||||
|
var url = targetUrl
|
||||||
|
if (req != null) {
|
||||||
|
req.url?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
req.headers.let {
|
||||||
|
proxyHeaders.clear()
|
||||||
|
proxyHeaders.putAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = Uri.parse(url);
|
||||||
if(_injectHost)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||||
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
_ignoreRequestHeaders.add("referer");
|
_ignoreRequestHeaders.add("referer");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||||
|
_requestModifier = modifier;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HttpProxyHandler"
|
private const val TAG = "HttpProxyHandler"
|
||||||
|
|||||||
@@ -54,14 +54,16 @@ interface IPlatformChannelContent : IPlatformContent {
|
|||||||
val subscribers: Long?
|
val subscribers: Long?
|
||||||
}
|
}
|
||||||
|
|
||||||
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
open class JSChannelContent(
|
||||||
override val contentType: ContentType get() = ContentType.CHANNEL
|
config: SourcePluginConfig,
|
||||||
override val thumbnail: String?
|
obj: V8ValueObject
|
||||||
override val subscribers: Long?
|
) : JSContent(config, obj), IPlatformChannelContent {
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
final override val contentType: ContentType = ContentType.CHANNEL
|
||||||
val contextName = "Channel";
|
|
||||||
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
override val thumbnail: String? =
|
||||||
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
|
||||||
}
|
|
||||||
}
|
override val subscribers: Long? =
|
||||||
|
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
|
||||||
|
}
|
||||||
|
|||||||
+11
-21
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
|||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
open class PlatformComment : IPlatformComment {
|
open class PlatformComment(
|
||||||
override val contextUrl: String;
|
override val contextUrl: String,
|
||||||
override val author: PlatformAuthorLink;
|
override val author: PlatformAuthorLink,
|
||||||
override val message: String;
|
override val message: String,
|
||||||
override val rating: IRating;
|
override val rating: IRating,
|
||||||
override val date: OffsetDateTime;
|
override val date: OffsetDateTime,
|
||||||
|
override val replyCount: Int? = null
|
||||||
|
) : IPlatformComment {
|
||||||
|
|
||||||
override val replyCount: Int?;
|
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
|
||||||
|
NoCommentsPager()
|
||||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) {
|
}
|
||||||
this.contextUrl = contextUrl;
|
|
||||||
this.author = author;
|
|
||||||
this.message = msg;
|
|
||||||
this.rating = rating;
|
|
||||||
this.date = date;
|
|
||||||
this.replyCount = replyCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
|
||||||
return NoCommentsPager();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+3
@@ -12,6 +12,9 @@ class DashManifestSource : IVideoSource, IDashManifestSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -12,6 +12,9 @@ class HLSManifestSource : IVideoSource, IHLSManifestSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
constructor(url : String) {
|
constructor(url : String) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -14,6 +14,9 @@ class HLSVariantVideoUrlSource(
|
|||||||
override val priority: Boolean,
|
override val priority: Boolean,
|
||||||
val url: String
|
val url: String
|
||||||
) : IVideoUrlSource {
|
) : IVideoUrlSource {
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,7 @@ class HLSVariantSubtitleUrlSource(
|
|||||||
override val format: String,
|
override val format: String,
|
||||||
) : ISubtitleSource {
|
) : ISubtitleSource {
|
||||||
override val hasFetch: Boolean = false
|
override val hasFetch: Boolean = false
|
||||||
|
override val language: String? = null
|
||||||
|
|
||||||
override fun getSubtitles(): String? {
|
override fun getSubtitles(): String? {
|
||||||
return null
|
return null
|
||||||
|
|||||||
+2
@@ -9,4 +9,6 @@ interface IVideoSource {
|
|||||||
val bitrate : Int?;
|
val bitrate : Int?;
|
||||||
val duration: Long;
|
val duration: Long;
|
||||||
val priority: Boolean;
|
val priority: Boolean;
|
||||||
|
val language: String?;
|
||||||
|
val original: Boolean?;
|
||||||
}
|
}
|
||||||
+4
-1
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
|
|||||||
override val name: String;
|
override val name: String;
|
||||||
override val url: String?;
|
override val url: String?;
|
||||||
override val format: String?;
|
override val format: String?;
|
||||||
|
override val language: String?
|
||||||
override val hasFetch: Boolean get() = false;
|
override val hasFetch: Boolean get() = false;
|
||||||
|
|
||||||
val filePath: String;
|
val filePath: String;
|
||||||
|
|
||||||
constructor(name: String, format: String?, filePath: String) {
|
constructor(name: String, language: String?, format: String?, filePath: String) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.format = format;
|
this.format = format;
|
||||||
|
this.language = language
|
||||||
this.filePath = filePath;
|
this.filePath = filePath;
|
||||||
this.url = Uri.fromFile(File(filePath)).toString();
|
this.url = Uri.fromFile(File(filePath)).toString();
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
|
|||||||
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
|
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
|
||||||
return LocalSubtitleSource(
|
return LocalSubtitleSource(
|
||||||
source.name,
|
source.name,
|
||||||
|
source.language,
|
||||||
source.format,
|
source.format,
|
||||||
path
|
path
|
||||||
);
|
);
|
||||||
|
|||||||
+4
@@ -16,6 +16,10 @@ class LocalVideoSource : IVideoSource, IStreamMetaDataSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
|
|
||||||
val filePath : String;
|
val filePath : String;
|
||||||
val fileSize : Long;
|
val fileSize : Long;
|
||||||
|
|
||||||
|
|||||||
+1
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
|||||||
@kotlinx.serialization.Serializable
|
@kotlinx.serialization.Serializable
|
||||||
class SubtitleRawSource(
|
class SubtitleRawSource(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
|
override val language: String?,
|
||||||
override val format: String?,
|
override val format: String?,
|
||||||
val _subtitles: String,
|
val _subtitles: String,
|
||||||
override val url: String? = null,
|
override val url: String? = null,
|
||||||
|
|||||||
+3
@@ -19,6 +19,9 @@ open class VideoUrlSource(
|
|||||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
override var streamMetaData: StreamMetaData? = null;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
override fun getVideoUrl() : String {
|
override fun getVideoUrl() : String {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ interface ISubtitleSource {
|
|||||||
val url: String?;
|
val url: String?;
|
||||||
val format: String?;
|
val format: String?;
|
||||||
val hasFetch: Boolean;
|
val hasFetch: Boolean;
|
||||||
|
val language: String?
|
||||||
|
|
||||||
fun getSubtitles(): String?;
|
fun getSubtitles(): String?;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -73,10 +73,10 @@ open class LocalVideoDetails(
|
|||||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||||
(LocalVideoUnMuxedSourceDescriptor(
|
(LocalVideoUnMuxedSourceDescriptor(
|
||||||
arrayOf(),
|
arrayOf(),
|
||||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name, duration))
|
||||||
))
|
))
|
||||||
else (LocalVideoMuxedSourceDescriptor(
|
else (LocalVideoMuxedSourceDescriptor(
|
||||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
LocalVideoContentSource(url, mimeType ?: "", name, duration)
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
|||||||
import com.futo.platformplayer.engine.exceptions.ScriptException
|
import com.futo.platformplayer.engine.exceptions.ScriptException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
import com.futo.platformplayer.engine.exceptions.ScriptValidationException
|
||||||
|
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
@@ -103,7 +104,7 @@ open class JSClient : IPlatformClient {
|
|||||||
|
|
||||||
override val id: String get() = config.id;
|
override val id: String get() = config.id;
|
||||||
override val name: String get() = config.name;
|
override val name: String get() = config.name;
|
||||||
override val icon: ImageVariable;
|
override val icon: ImageVariable get() = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null)
|
||||||
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
|
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
|
||||||
|
|
||||||
private var _busyAction = "";
|
private var _busyAction = "";
|
||||||
@@ -147,16 +148,16 @@ open class JSClient : IPlatformClient {
|
|||||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
|
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this.config = descriptor.config;
|
this.config = descriptor.config;
|
||||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
|
||||||
this.descriptor = descriptor;
|
this.descriptor = descriptor;
|
||||||
_injectedSaveState = saveState;
|
_injectedSaveState = saveState;
|
||||||
_auth = descriptor.getAuth();
|
_auth = descriptor.getAuth();
|
||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, null, _httpClient, _httpClientAuth);
|
||||||
|
_plugin.bridge.descriptor = descriptor;
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
|
|
||||||
@@ -178,7 +179,6 @@ open class JSClient : IPlatformClient {
|
|||||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this.config = descriptor.config;
|
this.config = descriptor.config;
|
||||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
|
||||||
this.descriptor = descriptor;
|
this.descriptor = descriptor;
|
||||||
_injectedSaveState = saveState;
|
_injectedSaveState = saveState;
|
||||||
if(!withoutCredentials)
|
if(!withoutCredentials)
|
||||||
@@ -188,9 +188,10 @@ open class JSClient : IPlatformClient {
|
|||||||
_captcha = descriptor.getCaptchaData();
|
_captcha = descriptor.getCaptchaData();
|
||||||
flags = descriptor.flags.toTypedArray();
|
flags = descriptor.flags.toTypedArray();
|
||||||
|
|
||||||
_httpClient = JSHttpClient(this, null, _captcha);
|
_httpClient = JSHttpClient(this, null, _captcha, config);
|
||||||
_httpClientAuth = JSHttpClient(this, _auth, _captcha);
|
_httpClientAuth = JSHttpClient(this, _auth, _captcha, config);
|
||||||
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
_plugin = V8Plugin(context, descriptor.config, script, _httpClient, _httpClientAuth);
|
||||||
|
_plugin.bridge.descriptor = descriptor;
|
||||||
_plugin.withDependency(context, "scripts/polyfil.js");
|
_plugin.withDependency(context, "scripts/polyfil.js");
|
||||||
_plugin.withDependency(context, "scripts/source.js");
|
_plugin.withDependency(context, "scripts/source.js");
|
||||||
_plugin.withScript(script);
|
_plugin.withScript(script);
|
||||||
@@ -487,13 +488,14 @@ open class JSClient : IPlatformClient {
|
|||||||
if (_peekChannelTypes != null) {
|
if (_peekChannelTypes != null) {
|
||||||
return _peekChannelTypes!!;
|
return _peekChannelTypes!!;
|
||||||
}
|
}
|
||||||
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
|
return busy {
|
||||||
|
val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()");
|
||||||
_peekChannelTypes = arr.keys.mapNotNull {
|
_peekChannelTypes = arr.keys.mapNotNull {
|
||||||
val str = arr.get<V8ValueString>(it);
|
val str = arr.get<V8ValueString>(it);
|
||||||
return@mapNotNull str.value;
|
return@mapNotNull str.value;
|
||||||
};
|
};
|
||||||
return _peekChannelTypes ?: listOf();
|
return@busy _peekChannelTypes ?: listOf();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
announcePluginUnhandledException("getPeekChannelTypes", ex);
|
announcePluginUnhandledException("getPeekChannelTypes", ex);
|
||||||
@@ -522,10 +524,12 @@ open class JSClient : IPlatformClient {
|
|||||||
if(!capabilities.hasGetChannelUrlByClaim)
|
if(!capabilities.hasGetChannelUrlByClaim)
|
||||||
throw IllegalStateException("This plugin does not support channel url by claim");
|
throw IllegalStateException("This plugin does not support channel url by claim");
|
||||||
|
|
||||||
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
|
return busy {
|
||||||
if(value !is V8ValueString)
|
val value = plugin.executeTyped<V8Value>("source.getChannelUrlByClaim(${claimType}, ${Json.encodeToString(claimValues)})");
|
||||||
return null;
|
if(value !is V8ValueString)
|
||||||
return value.value;
|
return@busy null;
|
||||||
|
return@busy value.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@JSOptional
|
@JSOptional
|
||||||
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
|
@JSDocs(12, "source.getChannelTemplateByClaimMap()", "Get a map for every supported claimtype mapping field to urls")
|
||||||
@@ -535,28 +539,30 @@ open class JSClient : IPlatformClient {
|
|||||||
if(!capabilities.hasGetChannelTemplateByClaimMap)
|
if(!capabilities.hasGetChannelTemplateByClaimMap)
|
||||||
throw IllegalStateException("This plugin does not support channel template by claim map");
|
throw IllegalStateException("This plugin does not support channel template by claim map");
|
||||||
|
|
||||||
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
|
return busy {
|
||||||
if(value !is V8ValueObject)
|
val value = plugin.executeTyped<V8Value>("source.getChannelTemplateByClaimMap()");
|
||||||
return mapOf();
|
if(value !is V8ValueObject)
|
||||||
|
return@busy mapOf();
|
||||||
|
|
||||||
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
|
val claimTypes = mutableMapOf<Int, Map<Int, String>>();
|
||||||
|
|
||||||
val keys = value.ownPropertyNames;
|
val keys = value.ownPropertyNames;
|
||||||
for(key in keys.toArray()) {
|
for(key in keys.toArray()) {
|
||||||
if(key is V8ValueInteger) {
|
if(key is V8ValueInteger) {
|
||||||
val map = value.get<V8ValueObject>(key);
|
val map = value.get<V8ValueObject>(key);
|
||||||
val mapKeys = map.ownPropertyNames;
|
val mapKeys = map.ownPropertyNames;
|
||||||
|
|
||||||
claimTypes[key.value] = mapKeys.toArray().filter {
|
claimTypes[key.value] = mapKeys.toArray().filter {
|
||||||
it is V8ValueInteger
|
it is V8ValueInteger
|
||||||
}.associate {
|
}.associate {
|
||||||
val mapKey = (it as V8ValueInteger).value;
|
val mapKey = (it as V8ValueInteger).value;
|
||||||
return@associate Pair(mapKey, map.getString(mapKey));
|
return@associate Pair(mapKey, map.getString(mapKey));
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
channelClaimTemplates = claimTypes.toMap();
|
||||||
|
return@busy claimTypes;
|
||||||
}
|
}
|
||||||
channelClaimTemplates = claimTypes.toMap();
|
|
||||||
return claimTypes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -697,27 +703,33 @@ open class JSClient : IPlatformClient {
|
|||||||
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
|
@JSDocs(22, "source.getUserPlaylists()", "Gets the playlist of the current user")
|
||||||
override fun getUserPlaylists(): Array<String> {
|
override fun getUserPlaylists(): Array<String> {
|
||||||
ensureEnabled();
|
ensureEnabled();
|
||||||
return plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
|
return busy {
|
||||||
.toArray()
|
return@busy plugin.executeTyped<V8ValueArray>("source.getUserPlaylists()")
|
||||||
.map { (it as V8ValueString).value }
|
.toArray()
|
||||||
.toTypedArray();
|
.map { (it as V8ValueString).value }
|
||||||
|
.toTypedArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JSOptional
|
@JSOptional
|
||||||
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
|
@JSDocs(23, "source.getUserSubscriptions()", "Gets the subscriptions of the current user")
|
||||||
override fun getUserSubscriptions(): Array<String> {
|
override fun getUserSubscriptions(): Array<String> {
|
||||||
ensureEnabled();
|
ensureEnabled();
|
||||||
return plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
|
return busy {
|
||||||
.toArray()
|
return@busy plugin.executeTyped<V8ValueArray>("source.getUserSubscriptions()")
|
||||||
.map { (it as V8ValueString).value }
|
.toArray()
|
||||||
.toTypedArray();
|
.map { (it as V8ValueString).value }
|
||||||
|
.toTypedArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JSOptional
|
@JSOptional
|
||||||
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||||
override fun getUserHistory(): IPager<IPlatformContent> {
|
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||||
ensureEnabled();
|
ensureEnabled();
|
||||||
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
return isBusyWith("getUserHistory") {
|
||||||
|
return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun validate() {
|
fun validate() {
|
||||||
@@ -893,4 +905,4 @@ open class JSClient : IPlatformClient {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toEncrypted(): String{
|
fun toEncrypted(): String{
|
||||||
@@ -15,23 +15,25 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun serialize(): String {
|
private fun serialize(): String {
|
||||||
return Json.encodeToString(SerializedAuth(cookieMap, headers));
|
return Json.encodeToString(SerializedAuth(cookieMap, headers, userAgent));
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "SourceAuth";
|
val TAG = "SourceAuth";
|
||||||
|
private val _json = Json { ignoreUnknownKeys = true };
|
||||||
|
|
||||||
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
fun fromEncrypted(encrypted: String?): SourceAuth? {
|
||||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deserialize(str: String): SourceAuth {
|
private fun deserialize(str: String): SourceAuth {
|
||||||
val data = Json.decodeFromString<SerializedAuth>(str);
|
val data = _json.decodeFromString<SerializedAuth>(str);
|
||||||
return SourceAuth(data.cookieMap, data.headers);
|
return SourceAuth(data.cookieMap, data.headers, data.userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
data class SerializedAuth(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||||
val headers: Map<String, Map<String, String>> = mapOf())
|
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||||
|
val userAgent: String? = null)
|
||||||
}
|
}
|
||||||
+8
-6
@@ -5,9 +5,9 @@ import kotlinx.serialization.decodeFromString
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
|
data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf(), val userAgent: String? = null) {
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return "(headers: '$headers', cookieString: '$cookieMap')";
|
return "(headers: '$headers', cookieString: '$cookieMap', userAgent: '$userAgent')";
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toEncrypted(): String{
|
fun toEncrypted(): String{
|
||||||
@@ -15,23 +15,25 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun serialize(): String {
|
private fun serialize(): String {
|
||||||
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers));
|
return Json.encodeToString(SerializedCaptchaData(cookieMap, headers, userAgent));
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = "SourceCaptchaData";
|
val TAG = "SourceCaptchaData";
|
||||||
|
private val _json = Json { ignoreUnknownKeys = true };
|
||||||
|
|
||||||
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
|
||||||
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deserialize(str: String): SourceCaptchaData {
|
fun deserialize(str: String): SourceCaptchaData {
|
||||||
val data = Json.decodeFromString<SerializedCaptchaData>(str);
|
val data = _json.decodeFromString<SerializedCaptchaData>(str);
|
||||||
return SourceCaptchaData(data.cookieMap, data.headers);
|
return SourceCaptchaData(data.cookieMap, data.headers, data.userAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
data class SerializedCaptchaData(val cookieMap: HashMap<String, HashMap<String, String>>?,
|
||||||
val headers: Map<String, Map<String, String>> = mapOf())
|
val headers: Map<String, Map<String, String>> = mapOf(),
|
||||||
|
val userAgent: String? = null)
|
||||||
}
|
}
|
||||||
+2
-1
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
|||||||
|
|
||||||
import kotlinx.serialization.Contextual
|
import kotlinx.serialization.Contextual
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
import java.util.Dictionary
|
import java.util.Dictionary
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -27,7 +28,7 @@ class SourcePluginAuthConfig(
|
|||||||
val details: String? = null,
|
val details: String? = null,
|
||||||
val once: Boolean? = true
|
val once: Boolean? = true
|
||||||
) {
|
) {
|
||||||
@Contextual
|
@Transient
|
||||||
private var _regex: Regex? = null;
|
private var _regex: Regex? = null;
|
||||||
|
|
||||||
fun getRegex(): Regex {
|
fun getRegex(): Regex {
|
||||||
|
|||||||
+14
-2
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
|||||||
//Script
|
//Script
|
||||||
val repositoryUrl: String? = null,
|
val repositoryUrl: String? = null,
|
||||||
val scriptUrl: String = "",
|
val scriptUrl: String = "",
|
||||||
val version: Int = -1,
|
var version: Int = -1,
|
||||||
|
|
||||||
val iconUrl: String? = null,
|
val iconUrl: String? = null,
|
||||||
var id: String = UUID.randomUUID().toString(),
|
var id: String = UUID.randomUUID().toString(),
|
||||||
@@ -61,6 +61,11 @@ class SourcePluginConfig(
|
|||||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||||
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
val absoluteScriptUrl: String get() = resolveAbsoluteUrl(scriptUrl, sourceUrl)!!;
|
||||||
|
|
||||||
|
fun isOfficialAuthor(): Boolean {
|
||||||
|
return scriptSignature != null &&
|
||||||
|
scriptPublicKey == "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsoFJU4AReDyUnSQI9A99UjLCwkY8OH+1o8cdtf2EjSb+fO2qmP8MGMTAvfvgmq5d2QBJE2XHRkRO3JKcTlcc1j0WlOlU8P9W272DYCeX6oYaavpKNqGKoGEuodp9wtiyNwyH46++JfpU/uIUacZbZKkHv9gIGchmNvpKYZQjFd/8pUqXGpcXZP54tGSC9PLcY+5TozZThK7Oy1+3YEf1bZ44UinRYYATbLk/wNuAfsupvlt6nxZOcJhABhdo9V+gY0FE6Ayg5+1cd1noWhnRtLF+sPdEr3z8Nt15JEK5a/524t25FMhwz8yKxlGW5qW3QLJHSUgLQncL6a1zlZ1s8QIDAQAB"
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
private fun resolveAbsoluteUrl(url: String?, sourceUrl: String?): String? {
|
||||||
if(url == null)
|
if(url == null)
|
||||||
return null;
|
return null;
|
||||||
@@ -165,6 +170,12 @@ class SourcePluginConfig(
|
|||||||
"Unrestricted Http Header access",
|
"Unrestricted Http Header access",
|
||||||
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
"Allows this plugin to access all headers (including cookies and authorization headers) for unauthenticated requests."
|
||||||
))
|
))
|
||||||
|
/*if(packagesOptional.contains("Browser") || packages.contains("Browser")) {
|
||||||
|
list.add(Pair(
|
||||||
|
"Browser Interop",
|
||||||
|
"This plugin requires webbrowser interop. May access urls outside of the restricted urls. This will only work for official plugins and during development builds."
|
||||||
|
))
|
||||||
|
}*/
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -224,7 +235,8 @@ class SourcePluginConfig(
|
|||||||
val variable: String? = null,
|
val variable: String? = null,
|
||||||
val dependency: String? = null,
|
val dependency: String? = null,
|
||||||
val warningDialog: String? = null,
|
val warningDialog: String? = null,
|
||||||
val options: List<String>? = null
|
val options: List<String>? = null,
|
||||||
|
val isAdvanced: Boolean? = null
|
||||||
) {
|
) {
|
||||||
val variableOrName: String get() = variable ?: name;
|
val variableOrName: String get() = variable ?: name;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -100,7 +100,7 @@ class SourcePluginDescriptor {
|
|||||||
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
@FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, -1)
|
||||||
var checkForUpdates: Boolean = true;
|
var checkForUpdates: Boolean = true;
|
||||||
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
@FormField(R.string.automatic_update_setting, FieldForm.TOGGLE, R.string.automatic_update_setting_description, 0)
|
||||||
var automaticUpdate: Boolean = false;
|
var automaticUpdate: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
@FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2)
|
||||||
var tabEnabled = TabEnabled();
|
var tabEnabled = TabEnabled();
|
||||||
|
|||||||
+71
@@ -23,6 +23,7 @@ import java.util.UUID
|
|||||||
class JSHttpClient : ManagedHttpClient {
|
class JSHttpClient : ManagedHttpClient {
|
||||||
private val _jsClient: JSClient?;
|
private val _jsClient: JSClient?;
|
||||||
private val _jsConfig: SourcePluginConfig?;
|
private val _jsConfig: SourcePluginConfig?;
|
||||||
|
val config get() = _jsConfig
|
||||||
private val _auth: SourceAuth?;
|
private val _auth: SourceAuth?;
|
||||||
private val _captcha: SourceCaptchaData?;
|
private val _captcha: SourceCaptchaData?;
|
||||||
|
|
||||||
@@ -254,6 +255,76 @@ class JSHttpClient : ManagedHttpClient {
|
|||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
fun processRequest(method: String, responseCode: Int, url: Uri, headers: Map<String, List<String>>) {
|
||||||
|
if(doUpdateCookies) {
|
||||||
|
val domain = url.host?.lowercase() ?: return;
|
||||||
|
val domainParts = domain.split(".");
|
||||||
|
val defaultCookieDomain =
|
||||||
|
"." + domainParts.drop(domainParts.size - 2).joinToString(".");
|
||||||
|
for (header in headers) {
|
||||||
|
if(header.key.lowercase() == "set-cookie") {
|
||||||
|
var domainToUse = domain;
|
||||||
|
val cookie = cookieStringToPair(header.value.first());
|
||||||
|
var cookieValue = cookie.second;
|
||||||
|
|
||||||
|
if (cookie.first.isNotEmpty() && cookie.second.isNotEmpty()) {
|
||||||
|
val cookieParts = cookie.second.split(";");
|
||||||
|
if (cookieParts.size == 0)
|
||||||
|
continue;
|
||||||
|
cookieValue = cookieParts[0].trim();
|
||||||
|
|
||||||
|
val cookieVariables = cookieParts.drop(1).map {
|
||||||
|
val splitIndex = it.indexOf("=");
|
||||||
|
if (splitIndex < 0)
|
||||||
|
return@map Pair(it.trim().lowercase(), "");
|
||||||
|
return@map Pair<String, String>(
|
||||||
|
it.substring(0, splitIndex).lowercase().trim(),
|
||||||
|
it.substring(splitIndex + 1).trim()
|
||||||
|
);
|
||||||
|
}.toMap();
|
||||||
|
domainToUse = if (cookieVariables.containsKey("domain"))
|
||||||
|
cookieVariables["domain"]!!.lowercase();
|
||||||
|
else defaultCookieDomain;
|
||||||
|
//TODO: Make sure this has no negative effect besides apply cookies to root domain
|
||||||
|
if(!domainToUse.startsWith("."))
|
||||||
|
domainToUse = ".${domainToUse}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((_auth != null || _currentCookieMap.isNotEmpty())) {
|
||||||
|
val cookieMap = if (_currentCookieMap.containsKey(domainToUse))
|
||||||
|
_currentCookieMap[domainToUse]!!;
|
||||||
|
else {
|
||||||
|
val newMap = hashMapOf<String, String>();
|
||||||
|
_currentCookieMap[domainToUse] = newMap
|
||||||
|
newMap;
|
||||||
|
}
|
||||||
|
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||||
|
cookieMap[cookie.first] = cookieValue;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
val cookieMap = if (_otherCookieMap.containsKey(domainToUse))
|
||||||
|
_otherCookieMap[domainToUse]!!;
|
||||||
|
else {
|
||||||
|
val newMap = hashMapOf<String, String>();
|
||||||
|
_otherCookieMap[domainToUse] = newMap
|
||||||
|
newMap;
|
||||||
|
}
|
||||||
|
if (cookieMap.containsKey(cookie.first) || doAllowNewCookies)
|
||||||
|
cookieMap[cookie.first] = cookieValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(_jsClient is DevJSClient) {
|
||||||
|
//val peekBody = resp.peekBody(1000 * 1000).string();
|
||||||
|
StateDeveloper.instance.addDevHttpExchange(
|
||||||
|
StateDeveloper.DevHttpExchange(
|
||||||
|
StateDeveloper.DevHttpRequest(method, url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), ""),
|
||||||
|
StateDeveloper.DevHttpRequest("RESP", url.toString(), mapOf(*headers.map { Pair(it.key, it.value.joinToString(";")) }.toTypedArray()), "", responseCode)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
private fun cookieStringToPair(cookie: String): Pair<String, String> {
|
||||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||||
|
|||||||
+17
-11
@@ -21,19 +21,25 @@ import com.futo.platformplayer.api.media.structures.IPager
|
|||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.getOrThrowNullableList
|
import com.futo.platformplayer.getOrThrowNullableList
|
||||||
|
import com.futo.platformplayer.getSourcePlugin
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
|
||||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
open class JSArticle(
|
||||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
config: SourcePluginConfig,
|
||||||
|
obj: V8ValueObject
|
||||||
|
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
|
||||||
|
|
||||||
override val summary: String;
|
final override val contentType: ContentType = ContentType.ARTICLE
|
||||||
override val thumbnails: Thumbnails?;
|
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
override val summary: String =
|
||||||
val contextName = "PlatformArticle";
|
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
|
||||||
|
|
||||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
override val thumbnails: Thumbnails? =
|
||||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
if (obj.getSourcePlugin()?.busy { obj.has("thumbnails") } ?: obj.has("thumbnails"))
|
||||||
|
Thumbnails.fromV8(
|
||||||
}
|
config,
|
||||||
}
|
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
|
||||||
|
)
|
||||||
|
else
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|||||||
+54
-37
@@ -24,39 +24,47 @@ import com.futo.platformplayer.getOrThrowNullableList
|
|||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
|
||||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
open class JSArticleDetails(
|
||||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
private val client: JSClient,
|
||||||
|
obj: V8ValueObject
|
||||||
|
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||||
|
|
||||||
private val _hasGetComments: Boolean;
|
final override val contentType: ContentType = ContentType.ARTICLE
|
||||||
private val _hasGetContentRecommendations: Boolean;
|
|
||||||
|
|
||||||
override val rating: IRating;
|
private val _hasGetComments: Boolean = client.busy { _content.has("getComments") }
|
||||||
|
private val _hasGetContentRecommendations: Boolean = client.busy { _content.has("getContentRecommendations") }
|
||||||
|
|
||||||
override val summary: String;
|
override val rating: IRating = client.busy {
|
||||||
override val thumbnails: Thumbnails?;
|
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
|
||||||
override val segments: List<IJSArticleSegment>;
|
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
|
||||||
|
?: RatingLikes(0)
|
||||||
|
}
|
||||||
|
|
||||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
override val summary: String = client.busy {
|
||||||
val contextName = "PlatformArticle";
|
_content.getOrThrow(client.config, "summary", "PlatformArticle")
|
||||||
|
}
|
||||||
|
|
||||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
override val thumbnails: Thumbnails? = client.busy {
|
||||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
if (_content.has("thumbnails"))
|
||||||
if(_content.has("thumbnails"))
|
Thumbnails.fromV8(
|
||||||
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
|
client.config,
|
||||||
|
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
|
||||||
|
)
|
||||||
else
|
else
|
||||||
thumbnails = null;
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override val segments: List<IJSArticleSegment> = client.busy {
|
||||||
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
|
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
|
||||||
?.map { fromV8Segment(client, it) }
|
?.mapNotNull { fromV8Segment(client, it) }
|
||||||
?.filterNotNull() ?: listOf());
|
?: emptyList()
|
||||||
|
|
||||||
_hasGetComments = _content.has("getComments");
|
|
||||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
if(!_hasGetComments || _content.isClosed)
|
val canGetComments = this.client.busy {
|
||||||
|
_hasGetComments && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetComments)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
@@ -72,7 +80,10 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
|
|||||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||||
|
|
||||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||||
if(!_hasGetContentRecommendations || _content.isClosed)
|
val canGetContentRecommendations = this.client.busy {
|
||||||
|
_hasGetContentRecommendations && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetContentRecommendations)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
@@ -86,25 +97,31 @@ open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
return client.busy {
|
||||||
return JSContentPager(_pluginConfig, client, contentPager);
|
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||||
|
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
return client.busy {
|
||||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||||
|
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
|
fun fromV8Segment(client: JSClient, obj: V8ValueObject): IJSArticleSegment? {
|
||||||
if(!obj.has("type"))
|
return client.busy {
|
||||||
throw IllegalArgumentException("Object missing type field");
|
if(!obj.has("type"))
|
||||||
return when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
throw IllegalArgumentException("Object missing type field");
|
||||||
SegmentType.TEXT -> JSTextSegment(client, obj);
|
return@busy when(SegmentType.fromInt(obj.getOrThrow(client.config, "type", "JSArticle.Segment"))) {
|
||||||
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
SegmentType.TEXT -> JSTextSegment(client, obj);
|
||||||
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
SegmentType.IMAGES -> JSImagesSegment(client, obj);
|
||||||
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
SegmentType.HEADER -> JSHeaderSegment(client, obj);
|
||||||
else -> null;
|
SegmentType.NESTED -> JSNestedSegment(client, obj);
|
||||||
|
else -> null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,4 +192,4 @@ class JSNestedSegment: IJSArticleSegment {
|
|||||||
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
|
val nestedObj = obj.getOrThrow<V8ValueObject>(client.config, "nested", contextName, false);
|
||||||
nested = IJSContent.fromV8(client, nestedObj);
|
nested = IJSContent.fromV8(client, nestedObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-12
@@ -46,23 +46,45 @@ class JSComment : IPlatformComment {
|
|||||||
_comment = obj;
|
_comment = obj;
|
||||||
_plugin = plugin;
|
_plugin = plugin;
|
||||||
|
|
||||||
val contextName = "Comment";
|
var parsedContextUrl: String? = null;
|
||||||
contextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
|
var parsedAuthor: PlatformAuthorLink? = null;
|
||||||
author = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
|
var parsedMessage: String? = null;
|
||||||
message = _comment!!.getOrThrow(config, "message", contextName);
|
var parsedRating: IRating? = null;
|
||||||
rating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
|
var parsedDate: OffsetDateTime? = null;
|
||||||
date = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) }
|
var parsedReplyCount: Int? = null;
|
||||||
replyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
|
var parsedContext: Map<String, String>? = null;
|
||||||
context = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
|
var parsedHasGetReplies = false;
|
||||||
_hasGetReplies = _comment!!.has("getReplies");
|
|
||||||
|
plugin.busy {
|
||||||
|
val contextName = "Comment";
|
||||||
|
parsedContextUrl = _comment!!.getOrThrow(config, "contextUrl", contextName);
|
||||||
|
parsedAuthor = PlatformAuthorLink.fromV8(_config!!, _comment!!.getOrThrow(config, "author", contextName));
|
||||||
|
parsedMessage = _comment!!.getOrThrow(config, "message", contextName);
|
||||||
|
parsedRating = IRating.fromV8(config, _comment!!.getOrThrow(config, "rating", contextName));
|
||||||
|
parsedDate = _comment!!.getOrThrowNullable<Int>(config, "date", contextName)?.let { OffsetDateTime.of(LocalDateTime.ofEpochSecond(it.toLong(), 0, ZoneOffset.UTC), ZoneOffset.UTC) };
|
||||||
|
parsedReplyCount = _comment!!.getOrThrowNullable(config, "replyCount", contextName);
|
||||||
|
parsedContext = _comment!!.getOrDefault(config, "context", contextName, hashMapOf()) ?: hashMapOf();
|
||||||
|
parsedHasGetReplies = _comment!!.has("getReplies");
|
||||||
|
}
|
||||||
|
|
||||||
|
contextUrl = parsedContextUrl ?: "";
|
||||||
|
author = parsedAuthor ?: PlatformAuthorLink.UNKNOWN;
|
||||||
|
message = parsedMessage ?: "";
|
||||||
|
rating = parsedRating ?: throw IllegalStateException("Missing comment rating");
|
||||||
|
date = parsedDate;
|
||||||
|
replyCount = parsedReplyCount;
|
||||||
|
context = parsedContext ?: hashMapOf();
|
||||||
|
_hasGetReplies = parsedHasGetReplies;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
|
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
if(!_hasGetReplies)
|
if(!_hasGetReplies)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
|
|
||||||
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
val plugin = if(client is JSClient) client else throw NotImplementedError("Only implemented for JSClient");
|
||||||
return JSCommentPager(_config!!, plugin, obj);
|
return plugin.busy {
|
||||||
|
val obj = _comment!!.invokeV8<V8ValueObject>("getReplies", arrayOf<Any>());
|
||||||
|
return@busy JSCommentPager(_config!!, plugin, obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-36
@@ -11,56 +11,56 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
|||||||
import com.futo.platformplayer.decodeUnicode
|
import com.futo.platformplayer.decodeUnicode
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
import com.futo.platformplayer.getSourcePlugin
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
open class JSContent : IPlatformContent, IPluginSourced {
|
open class JSContent(
|
||||||
protected val _pluginConfig: SourcePluginConfig;
|
protected val _pluginConfig: SourcePluginConfig,
|
||||||
protected val _content : V8ValueObject;
|
protected val _content: V8ValueObject
|
||||||
|
) : IPlatformContent, IPluginSourced {
|
||||||
|
|
||||||
protected val _hasGetDetails: Boolean;
|
override val contentType: ContentType = ContentType.UNKNOWN
|
||||||
|
|
||||||
override val contentType: ContentType get() = ContentType.UNKNOWN;
|
protected val _hasGetDetails: Boolean =
|
||||||
|
_content.getSourcePlugin()?.busy { _content.has("getDetails") } ?: false
|
||||||
|
|
||||||
override val id: PlatformID;
|
override val id: PlatformID =
|
||||||
override val name: String;
|
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
|
||||||
override val author: PlatformAuthorLink;
|
|
||||||
override val datetime: OffsetDateTime?;
|
|
||||||
|
|
||||||
override val url: String;
|
override val name: String =
|
||||||
override val shareUrl: String;
|
HtmlCompat.fromHtml(
|
||||||
|
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
|
||||||
|
HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||||
|
).toString()
|
||||||
|
|
||||||
override val sourceConfig: SourcePluginConfig get() = _pluginConfig;
|
override val author: PlatformAuthorLink =
|
||||||
|
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
|
||||||
|
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
|
||||||
|
?: PlatformAuthorLink.UNKNOWN
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
|
private val _epoch: Long? =
|
||||||
_pluginConfig = config;
|
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
|
||||||
_content = obj;
|
|
||||||
|
|
||||||
val contextName = "PlatformContent";
|
override val datetime: OffsetDateTime? =
|
||||||
|
_epoch?.takeIf { it != 0L }?.let {
|
||||||
|
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
|
override val url: String =
|
||||||
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
|
||||||
|
|
||||||
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
|
override val shareUrl: String =
|
||||||
if(authorObj != null)
|
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
|
||||||
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
|
|
||||||
else
|
|
||||||
author = PlatformAuthorLink.UNKNOWN;
|
|
||||||
|
|
||||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
override val sourceConfig: SourcePluginConfig
|
||||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
get() = _pluginConfig
|
||||||
datetime = null;
|
|
||||||
else
|
|
||||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
|
||||||
url = _content.getOrThrow(config, "url", contextName);
|
|
||||||
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
|
|
||||||
|
|
||||||
_hasGetDetails = _content.has("getDetails");
|
fun getUnderlyingObject(): V8ValueObject? = _content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CTX = "PlatformContent"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun getUnderlyingObject(): V8ValueObject? {
|
|
||||||
return _content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ abstract class JSPager<T> : IPager<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun hasMorePages(): Boolean {
|
override fun hasMorePages(): Boolean {
|
||||||
return _hasMorePages && !pager.isClosed;
|
return plugin.getUnderlyingPlugin().busy {
|
||||||
|
_hasMorePages && !pager.isClosed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun nextPage() {
|
override fun nextPage() {
|
||||||
@@ -91,4 +93,4 @@ abstract class JSPager<T> : IPager<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract fun convertResult(obj: V8ValueObject): T;
|
abstract fun convertResult(obj: V8ValueObject): T;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -6,14 +6,16 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
|
|
||||||
open class JSPlaylist : JSContent, IPlatformPlaylist {
|
open class JSPlaylist(
|
||||||
override val contentType: ContentType get() = ContentType.PLAYLIST;
|
config: SourcePluginConfig,
|
||||||
override val thumbnail: String?;
|
obj: V8ValueObject
|
||||||
override val videoCount: Int;
|
) : JSContent(config, obj), IPlatformPlaylist {
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
override val contentType: ContentType = ContentType.PLAYLIST
|
||||||
val contextName = "Playlist";
|
|
||||||
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
|
override val thumbnail: String? =
|
||||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
|
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
|
||||||
}
|
|
||||||
}
|
override val videoCount: Int =
|
||||||
|
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
|
||||||
|
}
|
||||||
|
|||||||
+49
-21
@@ -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.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
|
import com.futo.platformplayer.getSourcePlugin
|
||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
|
||||||
@@ -30,52 +31,79 @@ class JSPostDetails : JSPost, IPlatformPost, IPlatformPostDetails {
|
|||||||
|
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||||
val contextName = "PlatformPostDetails";
|
var parsedRating: IRating? = null;
|
||||||
|
var parsedTextType: TextType? = null;
|
||||||
|
var parsedContent: String? = null;
|
||||||
|
var parsedHasGetComments = false;
|
||||||
|
var parsedHasGetContentRecommendations = false;
|
||||||
|
|
||||||
rating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
|
val parse = {
|
||||||
textType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
|
val contextName = "PlatformPostDetails";
|
||||||
content = obj.getOrDefault(config, "content", contextName, "") ?: "";
|
|
||||||
|
|
||||||
_hasGetComments = _content.has("getComments");
|
parsedRating = obj.getOrDefault<V8ValueObject>(config, "rating", contextName, null)?.let { IRating.fromV8(config, it, contextName) } ?: RatingLikes(0);
|
||||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
parsedTextType = TextType.fromInt((obj.getOrDefault<Int>(config, "textType", contextName, null) ?: 0));
|
||||||
|
parsedContent = obj.getOrDefault(config, "content", contextName, "") ?: "";
|
||||||
|
|
||||||
|
parsedHasGetComments = _content.has("getComments");
|
||||||
|
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||||
|
};
|
||||||
|
obj.getSourcePlugin()?.busy {
|
||||||
|
parse();
|
||||||
|
} ?: parse()
|
||||||
|
|
||||||
|
rating = parsedRating ?: RatingLikes(0);
|
||||||
|
textType = parsedTextType ?: TextType.RAW;
|
||||||
|
content = parsedContent ?: "";
|
||||||
|
_hasGetComments = parsedHasGetComments;
|
||||||
|
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
if(!_hasGetComments || _content.isClosed)
|
val jsClient = client as? JSClient;
|
||||||
|
if(jsClient == null)
|
||||||
|
return null;
|
||||||
|
val canGetComments = jsClient.busy {
|
||||||
|
_hasGetComments && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetComments)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
|
return StateDeveloper.instance.handleDevCall(client.devID, "videoDetail.getComments()") {
|
||||||
return@handleDevCall getCommentsJS(client);
|
return@handleDevCall getCommentsJS(client);
|
||||||
}
|
}
|
||||||
else if(client is JSClient)
|
return getCommentsJS(jsClient);
|
||||||
return getCommentsJS(client);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||||
|
|
||||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||||
if(!_hasGetContentRecommendations || _content.isClosed)
|
val jsClient = client as? JSClient;
|
||||||
|
if(jsClient == null)
|
||||||
|
return null;
|
||||||
|
val canGetContentRecommendations = jsClient.busy {
|
||||||
|
_hasGetContentRecommendations && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetContentRecommendations)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
|
return StateDeveloper.instance.handleDevCall(client.devID, "postDetail.getContentRecommendations()") {
|
||||||
return@handleDevCall getContentRecommendationsJS(client);
|
return@handleDevCall getContentRecommendationsJS(client);
|
||||||
}
|
}
|
||||||
else if(client is JSClient)
|
return getContentRecommendationsJS(jsClient);
|
||||||
return getContentRecommendationsJS(client);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
private fun getContentRecommendationsJS(client: JSClient): JSContentPager {
|
||||||
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
return client.busy {
|
||||||
return JSContentPager(_pluginConfig, client, contentPager);
|
val contentPager = _content.invokeV8<V8ValueObject>("getContentRecommendations", arrayOf<Any>());
|
||||||
|
return@busy JSContentPager(_pluginConfig, client, contentPager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
private fun getCommentsJS(client: JSClient): JSCommentPager {
|
||||||
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
return client.busy {
|
||||||
return JSCommentPager(_pluginConfig, client, commentPager);
|
val commentPager = _content.invokeV8<V8ValueObject>("getComments", arrayOf<Any>());
|
||||||
|
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-12
@@ -17,11 +17,14 @@ import com.futo.platformplayer.getOrThrow
|
|||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
import com.futo.platformplayer.invokeV8Void
|
import com.futo.platformplayer.invokeV8Void
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateDeveloper
|
import com.futo.platformplayer.states.StateDeveloper
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
|
||||||
class JSRequestExecutor {
|
class JSRequestExecutor: AutoCloseable {
|
||||||
private val _plugin: JSClient;
|
private val _plugin: JSClient;
|
||||||
private val _config: IV8PluginConfig;
|
private val _config: IV8PluginConfig;
|
||||||
private var _executor: V8ValueObject;
|
private var _executor: V8ValueObject;
|
||||||
@@ -29,26 +32,35 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
private val hasCleanup: Boolean;
|
private val hasCleanup: Boolean;
|
||||||
|
|
||||||
|
private var _cleanLock = Any();
|
||||||
|
private var _cleaned: Boolean = false;
|
||||||
|
|
||||||
constructor(plugin: JSClient, executor: V8ValueObject) {
|
constructor(plugin: JSClient, executor: V8ValueObject) {
|
||||||
this._plugin = plugin;
|
this._plugin = plugin;
|
||||||
this._executor = executor;
|
this._executor = executor;
|
||||||
this._config = plugin.config;
|
this._config = plugin.config;
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
|
|
||||||
urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
|
var parsedUrlPrefix: String? = null;
|
||||||
|
var parsedHasCleanup = false;
|
||||||
|
plugin.busy {
|
||||||
|
parsedUrlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
|
||||||
|
|
||||||
if(!executor.has("executeRequest"))
|
if(!executor.has("executeRequest"))
|
||||||
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
|
throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
|
||||||
hasCleanup = executor.has("cleanup");
|
parsedHasCleanup = executor.has("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPrefix = parsedUrlPrefix;
|
||||||
|
hasCleanup = parsedHasCleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Executor properties?
|
//TODO: Executor properties?
|
||||||
@Throws(ScriptException::class)
|
@Throws(ScriptException::class)
|
||||||
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
|
open fun executeRequest(method: String, url: String, body: ByteArray?, headers: Map<String, String>): ByteArray {
|
||||||
if (_executor.isClosed)
|
|
||||||
throw IllegalStateException("Executor object is closed");
|
|
||||||
|
|
||||||
return _plugin.getUnderlyingPlugin().busy {
|
return _plugin.getUnderlyingPlugin().busy {
|
||||||
|
if (_executor.isClosed)
|
||||||
|
throw IllegalStateException("Executor object is closed");
|
||||||
|
|
||||||
val result = if(_plugin is DevJSClient)
|
val result = if(_plugin is DevJSClient)
|
||||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||||
@@ -102,8 +114,14 @@ class JSRequestExecutor {
|
|||||||
|
|
||||||
|
|
||||||
open fun cleanup() {
|
open fun cleanup() {
|
||||||
if (!hasCleanup || _executor.isClosed)
|
_plugin.busy {
|
||||||
return;
|
synchronized(_cleanLock) {
|
||||||
|
if (!hasCleanup || _executor.isClosed || _cleaned)
|
||||||
|
return@busy;
|
||||||
|
_cleaned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.i("JSRequestExecutor", "JSRequestExecutor cleanup requested");
|
||||||
_plugin.busy {
|
_plugin.busy {
|
||||||
if(_plugin is DevJSClient)
|
if(_plugin is DevJSClient)
|
||||||
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
|
||||||
@@ -125,9 +143,25 @@ class JSRequestExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun finalize() {
|
override fun close() {
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closeAsync() {
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||||
|
try {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("JSRequestExecutor", "Cleanup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
protected fun finalize() {
|
||||||
|
cleanup();
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: are these available..?
|
//TODO: are these available..?
|
||||||
@@ -137,4 +171,4 @@ class ExecutorParameters {
|
|||||||
var rangeEnd: Int = -1;
|
var rangeEnd: Int = -1;
|
||||||
|
|
||||||
var segment: Int = -1;
|
var segment: Int = -1;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -36,11 +36,11 @@ class JSRequestModifier: IRequestModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
override fun modifyRequest(url: String, headers: Map<String, String>): IRequest {
|
||||||
if (_modifier.isClosed) {
|
|
||||||
return Request(url, headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _plugin.busy {
|
return _plugin.busy {
|
||||||
|
if (_modifier.isClosed) {
|
||||||
|
return@busy Request(url, headers);
|
||||||
|
}
|
||||||
|
|
||||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSRequestModifier", "builder.modifyRequest()") {
|
||||||
_modifier.invokeV8("modifyRequest", url, headers);
|
_modifier.invokeV8("modifyRequest", url, headers);
|
||||||
} as V8ValueObject;
|
} as V8ValueObject;
|
||||||
@@ -53,4 +53,4 @@ class JSRequestModifier: IRequestModifier {
|
|||||||
|
|
||||||
|
|
||||||
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
|
data class Request(override val url: String, override val headers: Map<String, String>) : IRequest;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-6
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
|||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.getSourcePlugin
|
import com.futo.platformplayer.getSourcePlugin
|
||||||
import com.futo.platformplayer.invokeV8
|
import com.futo.platformplayer.invokeV8
|
||||||
@@ -22,16 +23,34 @@ class JSSubtitleSource : ISubtitleSource {
|
|||||||
override val name: String;
|
override val name: String;
|
||||||
override val url: String?;
|
override val url: String?;
|
||||||
override val format: String?;
|
override val format: String?;
|
||||||
|
override val language: String?
|
||||||
override val hasFetch: Boolean;
|
override val hasFetch: Boolean;
|
||||||
|
|
||||||
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
|
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
|
||||||
_obj = v8Value;
|
_obj = v8Value;
|
||||||
|
|
||||||
val context = "JSSubtitles";
|
var parsedName: String? = null;
|
||||||
name = v8Value.getOrThrow(config, "name", context, false);
|
var parsedLanguage: String? = null;
|
||||||
url = v8Value.getOrThrow(config, "url", context, true);
|
var parsedUrl: String? = null;
|
||||||
format = v8Value.getOrThrow(config, "format", context, true);
|
var parsedFormat: String? = null;
|
||||||
hasFetch = v8Value.has("getSubtitles");
|
var parsedHasFetch = false;
|
||||||
|
val parse = {
|
||||||
|
val context = "JSSubtitles";
|
||||||
|
parsedName = v8Value.getOrThrow(config, "name", context, false);
|
||||||
|
parsedLanguage = v8Value.getOrDefault(config, "language", context, null);
|
||||||
|
parsedUrl = v8Value.getOrThrow(config, "url", context, true);
|
||||||
|
parsedFormat = v8Value.getOrThrow(config, "format", context, true);
|
||||||
|
parsedHasFetch = v8Value.has("getSubtitles");
|
||||||
|
};
|
||||||
|
v8Value.getSourcePlugin()?.busy {
|
||||||
|
parse();
|
||||||
|
} ?: parse()
|
||||||
|
|
||||||
|
name = parsedName ?: "";
|
||||||
|
language = parsedLanguage;
|
||||||
|
url = parsedUrl;
|
||||||
|
format = parsedFormat;
|
||||||
|
hasFetch = parsedHasFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSubtitles(): String {
|
override fun getSubtitles(): String {
|
||||||
@@ -66,4 +85,4 @@ class JSSubtitleSource : ISubtitleSource {
|
|||||||
return JSSubtitleSource(config, value);
|
return JSSubtitleSource(config, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-24
@@ -52,34 +52,63 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
override val subtitles: List<ISubtitleSource>;
|
override val subtitles: List<ISubtitleSource>;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||||
val contextName = "VideoDetails";
|
|
||||||
_plugin = plugin;
|
_plugin = plugin;
|
||||||
val config = plugin.config;
|
var parsedDescription: String? = null;
|
||||||
description = _content.getOrThrow(config, "description", contextName);
|
var parsedVideo: IVideoSourceDescriptor? = null;
|
||||||
video = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
|
var parsedDash: IDashManifestSource? = null;
|
||||||
dash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
|
var parsedHls: IHLSManifestSource? = null;
|
||||||
hls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
|
var parsedLive: IVideoSource? = null;
|
||||||
live = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
|
var parsedRating: IRating? = null;
|
||||||
rating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
|
var parsedSubtitles: List<ISubtitleSource>? = null;
|
||||||
|
var parsedHasGetComments = false;
|
||||||
|
var parsedHasGetPlaybackTracker = false;
|
||||||
|
var parsedHasGetContentRecommendations = false;
|
||||||
|
var parsedHasGetVODEvents = false;
|
||||||
|
|
||||||
if(!_content.has("subtitles"))
|
plugin.busy {
|
||||||
subtitles = listOf();
|
val contextName = "VideoDetails";
|
||||||
else {
|
val config = plugin.config;
|
||||||
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
|
parsedDescription = _content.getOrThrow(config, "description", contextName);
|
||||||
if(subArrs != null)
|
parsedVideo = JSVideoSourceDescriptor.fromV8(plugin, _content.getOrThrow(config, "video", contextName));
|
||||||
subtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
|
parsedDash = JSSource.fromV8DashNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "dash", contextName));
|
||||||
else
|
parsedHls = JSSource.fromV8HLSNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "hls", contextName));
|
||||||
subtitles = listOf();
|
parsedLive = JSSource.fromV8VideoNullable(plugin, _content.getOrThrowNullable<V8ValueObject>(config, "live", contextName));
|
||||||
|
parsedRating = IRating.fromV8OrDefault(config, _content.getOrDefault<V8ValueObject>(config, "rating", contextName, null), RatingLikes(0));
|
||||||
|
|
||||||
|
if(!_content.has("subtitles"))
|
||||||
|
parsedSubtitles = listOf();
|
||||||
|
else {
|
||||||
|
val subArrs = _content.getOrThrowNullable<V8ValueArray>(config, "subtitles", contextName);
|
||||||
|
if(subArrs != null)
|
||||||
|
parsedSubtitles = subArrs.keys.map { JSSubtitleSource.fromV8(config, subArrs.get(it)) };
|
||||||
|
else
|
||||||
|
parsedSubtitles = listOf();
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedHasGetComments = _content.has("getComments");
|
||||||
|
parsedHasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||||
|
parsedHasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||||
|
parsedHasGetVODEvents = _content.has("getVODEvents");
|
||||||
}
|
}
|
||||||
|
|
||||||
_hasGetComments = _content.has("getComments");
|
description = parsedDescription ?: "";
|
||||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
video = parsedVideo ?: throw IllegalStateException("Missing video source descriptor");
|
||||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
dash = parsedDash;
|
||||||
_hasGetVODEvents = _content.has("getVODEvents");
|
hls = parsedHls;
|
||||||
|
live = parsedLive;
|
||||||
|
rating = parsedRating ?: RatingLikes(0);
|
||||||
|
subtitles = parsedSubtitles ?: listOf();
|
||||||
|
_hasGetComments = parsedHasGetComments;
|
||||||
|
_hasGetPlaybackTracker = parsedHasGetPlaybackTracker;
|
||||||
|
_hasGetContentRecommendations = parsedHasGetContentRecommendations;
|
||||||
|
_hasGetVODEvents = parsedHasGetVODEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||||
if(!_hasGetPlaybackTracker || _content.isClosed)
|
val canGetPlaybackTracker = _plugin.busy {
|
||||||
|
_hasGetPlaybackTracker && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetPlaybackTracker)
|
||||||
return null;
|
return null;
|
||||||
if(_pluginConfig.id == StateDeveloper.DEV_ID)
|
if(_pluginConfig.id == StateDeveloper.DEV_ID)
|
||||||
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
|
return StateDeveloper.instance.handleDevCall(_pluginConfig.id, "videoDetail.getComments()") {
|
||||||
@@ -102,7 +131,10 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? {
|
||||||
if(!_hasGetContentRecommendations || _content.isClosed)
|
val canGetContentRecommendations = _plugin.busy {
|
||||||
|
_hasGetContentRecommendations && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetContentRecommendations)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
@@ -122,7 +154,12 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
if(client !is JSClient || !_hasGetComments || _content.isClosed)
|
if(client !is JSClient)
|
||||||
|
return null;
|
||||||
|
val canGetComments = _plugin.busy {
|
||||||
|
_hasGetComments && !_content.isClosed
|
||||||
|
}
|
||||||
|
if(!canGetComments)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if(client is DevJSClient)
|
if(client is DevJSClient)
|
||||||
@@ -153,4 +190,4 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
|||||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-30
@@ -8,43 +8,44 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
open class JSAudioUrlSource(
|
||||||
override val name: String;
|
plugin: JSClient,
|
||||||
override val bitrate : Int;
|
obj: V8ValueObject
|
||||||
override val container : String;
|
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
|
||||||
override val codec: String;
|
|
||||||
private val url : String;
|
|
||||||
|
|
||||||
override val language: String;
|
private val ctx = "AudioUrlSource"
|
||||||
|
private val cfg = plugin.config
|
||||||
|
|
||||||
override val duration: Long?;
|
override val bitrate: Int =
|
||||||
|
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
|
||||||
|
|
||||||
override var priority: Boolean = false;
|
override val container: String =
|
||||||
|
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||||
|
|
||||||
override var original: Boolean = false;
|
override val codec: String =
|
||||||
|
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
private val url: String =
|
||||||
val contextName = "AudioUrlSource";
|
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||||
val config = plugin.config;
|
|
||||||
|
|
||||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
override val language: String =
|
||||||
container = _obj.getOrThrow(config, "container", contextName);
|
_obj.getOrThrow<String>(cfg, "language", ctx)
|
||||||
codec = _obj.getOrThrow(config, "codec", contextName);
|
|
||||||
url = _obj.getOrThrow(config, "url", contextName);
|
|
||||||
language = _obj.getOrThrow(config, "language", contextName);
|
|
||||||
duration = _obj.getOrDefault(config, "duration", contextName, null);
|
|
||||||
|
|
||||||
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
|
override val duration: Long? =
|
||||||
|
_obj.getOrDefault<Long>(cfg, "duration", ctx, null)?.toLong()
|
||||||
|
|
||||||
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
|
override val name: String =
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
_obj.getOrDefault<String>(cfg, "name", ctx, null)
|
||||||
}
|
?: "$container $bitrate"
|
||||||
|
|
||||||
override fun getAudioUrl() : String {
|
override var priority: Boolean =
|
||||||
return url;
|
plugin.busy { if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false }
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
override var original: Boolean =
|
||||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
|
plugin.busy { if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false }
|
||||||
}
|
|
||||||
}
|
override fun getAudioUrl(): String = url
|
||||||
|
|
||||||
|
override fun toString(): String =
|
||||||
|
"(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"
|
||||||
|
}
|
||||||
|
|||||||
+12
-10
@@ -19,21 +19,23 @@ class JSAudioUrlWidevineSource : JSAudioUrlSource, IAudioUrlWidevineSource {
|
|||||||
val config = plugin.config
|
val config = plugin.config
|
||||||
|
|
||||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||||
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
return _plugin.busy {
|
||||||
return null
|
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !is V8ValueObject)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
|
return@busy JSRequestExecutor(_plugin, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result !is V8ValueObject)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return JSRequestExecutor(_plugin, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
|
|||||||
+5
-5
@@ -54,8 +54,8 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
||||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
||||||
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
hasGenerate = _obj.has("generate");
|
hasGenerate = plugin.busy { _obj.has("generate") };
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _pregenerate: V8Deferred<String?>? = null;
|
private var _pregenerate: V8Deferred<String?>? = null;
|
||||||
@@ -67,7 +67,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return V8Deferred(CompletableDeferred(manifest));
|
return V8Deferred(CompletableDeferred(manifest));
|
||||||
if(_obj.isClosed)
|
if(_plugin.busy { _obj.isClosed })
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
|
|
||||||
val pregenerated = _pregenerate;
|
val pregenerated = _pregenerate;
|
||||||
@@ -111,7 +111,7 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
override fun generate(): String? {
|
override fun generate(): String? {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return manifest;
|
return manifest;
|
||||||
if(_obj.isClosed)
|
if(_plugin.busy { _obj.isClosed })
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
|
|
||||||
val plugin = _plugin.getUnderlyingPlugin();
|
val plugin = _plugin.getUnderlyingPlugin();
|
||||||
@@ -145,4 +145,4 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-34
@@ -31,42 +31,57 @@ interface IJSDashManifestRawSource {
|
|||||||
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
||||||
fun generate(): String?;
|
fun generate(): String?;
|
||||||
}
|
}
|
||||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
open class JSDashManifestRawSource(
|
||||||
override val container : String;
|
plugin: JSClient,
|
||||||
override val name : String;
|
obj: V8ValueObject
|
||||||
override val width: Int;
|
) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||||
override val height: Int;
|
|
||||||
override val codec: String;
|
|
||||||
override val bitrate: Int?;
|
|
||||||
override val duration: Long;
|
|
||||||
override val priority: Boolean;
|
|
||||||
|
|
||||||
val url: String?;
|
private val ctx = "DashRawSource"
|
||||||
override var manifest: String?;
|
private val cfg = plugin.config
|
||||||
|
|
||||||
override val hasGenerate: Boolean;
|
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||||
val canMerge: Boolean;
|
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||||
|
|
||||||
override var streamMetaData: StreamMetaData? = null;
|
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
|
override val container: String =
|
||||||
val contextName = "DashRawSource";
|
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||||
val config = plugin.config;
|
|
||||||
name = _obj.getOrThrow(config, "name", contextName);
|
|
||||||
url = _obj.getOrThrow(config, "url", contextName);
|
|
||||||
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
|
|
||||||
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
|
|
||||||
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
|
|
||||||
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
|
|
||||||
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
|
|
||||||
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
|
|
||||||
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
|
|
||||||
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
|
|
||||||
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
|
|
||||||
hasGenerate = _obj.has("generate");
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _pregenerate: V8Deferred<String?>? = null;
|
override val name: String =
|
||||||
|
_obj.getOrThrow<String>(cfg, "name", ctx)
|
||||||
|
|
||||||
|
override val width: Int =
|
||||||
|
_obj.getOrDefault<Int>(cfg, "width", ctx, null)?.toInt() ?: 0
|
||||||
|
|
||||||
|
override val height: Int =
|
||||||
|
_obj.getOrDefault<Int>(cfg, "height", ctx, null)?.toInt() ?: 0
|
||||||
|
|
||||||
|
override val codec: String =
|
||||||
|
_obj.getOrDefault<String>(cfg, "codec", ctx, "") ?: ""
|
||||||
|
|
||||||
|
override val bitrate: Int? =
|
||||||
|
_obj.getOrDefault<Int>(cfg, "bitrate", ctx, null)?.toInt()
|
||||||
|
|
||||||
|
override val duration: Long =
|
||||||
|
_obj.getOrDefault<Long>(cfg, "duration", ctx, 0)?.toLong() ?: 0L
|
||||||
|
|
||||||
|
override val priority: Boolean =
|
||||||
|
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||||
|
|
||||||
|
val url: String? =
|
||||||
|
_obj.getOrDefault<String>(cfg, "url", ctx, null)
|
||||||
|
|
||||||
|
override var manifest: String? =
|
||||||
|
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
|
||||||
|
|
||||||
|
override val hasGenerate: Boolean = plugin.busy { _obj.has("generate") }
|
||||||
|
|
||||||
|
val canMerge: Boolean =
|
||||||
|
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
|
||||||
|
|
||||||
|
override var streamMetaData: StreamMetaData? = null
|
||||||
|
var audioStreamMetaData: StreamMetaData? = null
|
||||||
|
|
||||||
|
private var _pregenerate: V8Deferred<String?>? = null
|
||||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||||
_pregenerate = generateAsync(scope);
|
_pregenerate = generateAsync(scope);
|
||||||
return _pregenerate;
|
return _pregenerate;
|
||||||
@@ -75,7 +90,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return V8Deferred(CompletableDeferred(manifest));
|
return V8Deferred(CompletableDeferred(manifest));
|
||||||
if(_obj.isClosed)
|
if(_plugin.busy { _obj.isClosed })
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
val pregenerated = _pregenerate;
|
val pregenerated = _pregenerate;
|
||||||
if(pregenerated != null) {
|
if(pregenerated != null) {
|
||||||
@@ -111,6 +126,14 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
|
||||||
|
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
|
||||||
|
}
|
||||||
|
|
||||||
return@busy result.convert {
|
return@busy result.convert {
|
||||||
it.value
|
it.value
|
||||||
};
|
};
|
||||||
@@ -119,7 +142,7 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||||||
override open fun generate(): String? {
|
override open fun generate(): String? {
|
||||||
if(!hasGenerate)
|
if(!hasGenerate)
|
||||||
return manifest;
|
return manifest;
|
||||||
if(_obj.isClosed)
|
if(_plugin.busy { _obj.isClosed })
|
||||||
throw IllegalStateException("Source object already closed");
|
throw IllegalStateException("Source object already closed");
|
||||||
|
|
||||||
var result: String? = null;
|
var result: String? = null;
|
||||||
@@ -148,6 +171,14 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
|||||||
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
if(initEnd > 0 && indexStart > 0 && indexEnd > 0) {
|
||||||
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
streamMetaData = StreamMetaData(initStart, initEnd, indexStart, indexEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioInitStart = _obj.getOrDefault<Int>(_config, "audioInitStart", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioInitEnd = _obj.getOrDefault<Int>(_config, "audioInitEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioIndexStart = _obj.getOrDefault<Int>(_config, "audioIndexStart", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
val audioIndexEnd = _obj.getOrDefault<Int>(_config, "audioIndexEnd", "JSDashManifestRawSource", null) ?: 0;
|
||||||
|
if(audioInitEnd > 0 && audioIndexStart > 0 && audioIndexEnd > 0) {
|
||||||
|
audioStreamMetaData = StreamMetaData(audioInitStart, audioInitEnd, audioIndexStart, audioIndexEnd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -175,6 +206,9 @@ class JSDashManifestMergingRawSource(
|
|||||||
override val priority: Boolean
|
override val priority: Boolean
|
||||||
get() = video.priority;
|
get() = video.priority;
|
||||||
|
|
||||||
|
override val language: String? get() = audio.language
|
||||||
|
override val original: Boolean? get() = audio.original;
|
||||||
|
|
||||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||||
val videoDashDef = video.generateAsync(scope);
|
val videoDashDef = video.generateAsync(scope);
|
||||||
val audioDashDef = audio.generateAsync(scope);
|
val audioDashDef = audio.generateAsync(scope);
|
||||||
@@ -224,4 +258,4 @@ class JSDashManifestMergingRawSource(
|
|||||||
companion object {
|
companion object {
|
||||||
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
|
private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashSource";
|
val contextName = "DashSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -29,6 +32,9 @@ class JSDashManifestSource : IVideoUrlSource, IDashManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow(config, "duration", contextName);
|
duration = _obj.getOrThrow(config, "duration", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
|
||||||
|
language = obj.getOrNull(config, "language", contextName);
|
||||||
|
original = obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
|
|||||||
+19
-11
@@ -28,6 +28,9 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
override val licenseUri: String
|
override val licenseUri: String
|
||||||
override val hasLicenseRequestExecutor: Boolean
|
override val hasLicenseRequestExecutor: Boolean
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
@Suppress("ConvertSecondaryConstructorToPrimary")
|
@Suppress("ConvertSecondaryConstructorToPrimary")
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH, plugin, obj) {
|
||||||
val contextName = "DashWidevineSource"
|
val contextName = "DashWidevineSource"
|
||||||
@@ -39,24 +42,29 @@ class JSDashManifestWidevineSource : IVideoUrlSource, IDashManifestSource,
|
|||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false
|
priority = obj.getOrNull(config, "priority", contextName) ?: false
|
||||||
|
|
||||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
|
||||||
|
|
||||||
|
language = _obj.getOrNull(config, "language", contextName);
|
||||||
|
original = _obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||||
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
return _plugin.busy {
|
||||||
return null
|
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
|
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSDashManifestWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !is V8ValueObject)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
|
return@busy JSRequestExecutor(_plugin, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result !is V8ValueObject)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return JSRequestExecutor(_plugin, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getVideoUrl(): String {
|
override fun getVideoUrl(): String {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ class JSHLSManifestAudioSource : IHLSManifestAudioSource, JSSource {
|
|||||||
language = _obj.getOrThrow(config, "language", contextName);
|
language = _obj.getOrThrow(config, "language", contextName);
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
original = obj.getOrNull(config, "original", contextName) ?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
@@ -21,6 +21,9 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
|
|
||||||
override var priority: Boolean = false;
|
override var priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String?;
|
||||||
|
override val original: Boolean?;
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_HLS, plugin, obj) {
|
||||||
val contextName = "HLSSource";
|
val contextName = "HLSSource";
|
||||||
val config = plugin.config;
|
val config = plugin.config;
|
||||||
@@ -30,5 +33,8 @@ class JSHLSManifestSource : IHLSManifestSource, JSSource {
|
|||||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||||
|
|
||||||
|
language = _obj.getOrNull(config, "language", contextName);
|
||||||
|
original = _obj.getOrNull(config, "original", contextName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+19
-8
@@ -44,15 +44,26 @@ abstract class JSSource {
|
|||||||
this._obj = obj;
|
this._obj = obj;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
|
|
||||||
_requestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
|
var parsedRequestModifier: JSRequest? = null;
|
||||||
JSRequest(plugin, it, null, null, true);
|
var parsedHasRequestModifier = false;
|
||||||
}
|
var parsedRequestExecutor: JSRequest? = null;
|
||||||
hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
|
var parsedHasRequestExecutor = false;
|
||||||
|
plugin.busy {
|
||||||
|
parsedRequestModifier = obj.getOrDefault<V8ValueObject>(_config, "requestModifier", "JSSource.requestModifier", null)?.let {
|
||||||
|
JSRequest(plugin, it, null, null, true);
|
||||||
|
};
|
||||||
|
parsedHasRequestModifier = parsedRequestModifier != null || obj.has("getRequestModifier");
|
||||||
|
|
||||||
_requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
|
parsedRequestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
|
||||||
JSRequest(plugin, it, null, null, true);
|
JSRequest(plugin, it, null, null, true);
|
||||||
|
};
|
||||||
|
parsedHasRequestExecutor = parsedRequestExecutor != null || obj.has("getRequestExecutor");
|
||||||
}
|
}
|
||||||
hasRequestExecutor = _requestExecutor != null || obj.has("getRequestExecutor");
|
|
||||||
|
_requestModifier = parsedRequestModifier;
|
||||||
|
hasRequestModifier = parsedHasRequestModifier;
|
||||||
|
_requestExecutor = parsedRequestExecutor;
|
||||||
|
hasRequestExecutor = parsedHasRequestExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
|
fun getRequestModifier(): IRequestModifier? = _plugin.isBusyWith("getRequestModifier") {
|
||||||
@@ -166,4 +177,4 @@ abstract class JSSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-30
@@ -5,42 +5,50 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrNull
|
import com.futo.platformplayer.getOrNull
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
|
open class JSVideoUrlSource(
|
||||||
override val width : Int;
|
plugin: JSClient,
|
||||||
override val height : Int;
|
obj: V8ValueObject
|
||||||
override val container : String;
|
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
|
||||||
override val codec: String;
|
|
||||||
override val name : String;
|
|
||||||
override val bitrate : Int;
|
|
||||||
override val duration: Long;
|
|
||||||
private val url : String;
|
|
||||||
|
|
||||||
override var priority: Boolean = false;
|
private val ctx = "JSVideoUrlSource"
|
||||||
|
private val cfg = plugin.config
|
||||||
|
|
||||||
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
|
override val width: Int =
|
||||||
val contextName = "JSVideoUrlSource";
|
_obj.getOrThrow<Int>(cfg, "width", ctx)
|
||||||
val config = plugin.config;
|
|
||||||
|
|
||||||
width = _obj.getOrThrow(config, "width", contextName);
|
override val height: Int =
|
||||||
height = _obj.getOrThrow(config, "height", contextName);
|
_obj.getOrThrow<Int>(cfg, "height", ctx)
|
||||||
container = _obj.getOrThrow(config, "container", contextName);
|
|
||||||
codec = _obj.getOrThrow(config, "codec", contextName);
|
|
||||||
name = _obj.getOrThrow(config, "name", contextName);
|
|
||||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
|
||||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
|
||||||
url = _obj.getOrThrow(config, "url", contextName);
|
|
||||||
|
|
||||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
override val container: String =
|
||||||
}
|
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||||
|
|
||||||
override fun getVideoUrl() : String {
|
override val codec: String =
|
||||||
return url;
|
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
override val name: String =
|
||||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
_obj.getOrThrow<String>(cfg, "name", ctx)
|
||||||
}
|
|
||||||
}
|
override val bitrate: Int =
|
||||||
|
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
|
||||||
|
|
||||||
|
override val duration: Long =
|
||||||
|
_obj.getOrThrow<Long>(cfg, "duration", ctx)
|
||||||
|
|
||||||
|
private val url: String =
|
||||||
|
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||||
|
|
||||||
|
override var priority: Boolean =
|
||||||
|
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||||
|
|
||||||
|
override val language: String? = _obj.getOrDefault(cfg, "language", ctx, null);
|
||||||
|
override val original: Boolean? = _obj.getOrDefault(cfg, "original", ctx, null);
|
||||||
|
|
||||||
|
override fun getVideoUrl(): String = url
|
||||||
|
|
||||||
|
override fun toString(): String =
|
||||||
|
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||||
|
}
|
||||||
|
|||||||
+13
-11
@@ -18,25 +18,27 @@ class JSVideoUrlWidevineSource : JSVideoUrlSource, IVideoUrlWidevineSource {
|
|||||||
val config = plugin.config
|
val config = plugin.config
|
||||||
|
|
||||||
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
licenseUri = _obj.getOrThrow(config, "licenseUri", contextName)
|
||||||
hasLicenseRequestExecutor = obj.has("getLicenseRequestExecutor")
|
hasLicenseRequestExecutor = plugin.busy { obj.has("getLicenseRequestExecutor") }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
override fun getLicenseRequestExecutor(): JSRequestExecutor? {
|
||||||
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
return _plugin.busy {
|
||||||
return null
|
if (!hasLicenseRequestExecutor || _obj.isClosed)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSAudioUrlWidevineSource", "obj.getLicenseRequestExecutor()") {
|
||||||
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
_obj.invokeV8("getLicenseRequestExecutor", arrayOf<Any>())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !is V8ValueObject)
|
||||||
|
return@busy null
|
||||||
|
|
||||||
|
return@busy JSRequestExecutor(_plugin, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result !is V8ValueObject)
|
|
||||||
return null
|
|
||||||
|
|
||||||
return JSRequestExecutor(_plugin, result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
val url = getVideoUrl()
|
val url = getVideoUrl()
|
||||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
|
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url, hasLicenseRequestExecutor=$hasLicenseRequestExecutor, licenseUri=$licenseUri)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-2
@@ -1,5 +1,160 @@
|
|||||||
package com.futo.platformplayer.api.media.platforms.local
|
package com.futo.platformplayer.api.media.platforms.local
|
||||||
|
|
||||||
class LocalClient {
|
import android.content.ContentResolver
|
||||||
//TODO
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
|
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||||
|
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
|
||||||
|
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||||
|
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.contents.IPlatformContentDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||||
|
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.EmptyPager
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.states.StateLibrary
|
||||||
|
import java.net.MalformedURLException
|
||||||
|
|
||||||
|
class LocalClient: IPlatformClient {
|
||||||
|
override val id: String = "LOCAL"
|
||||||
|
override val name: String = "Local"
|
||||||
|
override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library)
|
||||||
|
override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities()
|
||||||
|
|
||||||
|
override fun initialize() {}
|
||||||
|
|
||||||
|
override fun disable() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getHome(): IPager<IPlatformContent>
|
||||||
|
= EmptyPager();
|
||||||
|
|
||||||
|
override fun isContentDetailsUrl(url: String): Boolean {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(url);
|
||||||
|
return ContentResolver.SCHEME_CONTENT == uri.scheme
|
||||||
|
&& (
|
||||||
|
MediaStore.AUTHORITY == uri.authority ||
|
||||||
|
uri.authority == "com.android.externalstorage.documents"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch(ex: MalformedURLException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
|
||||||
|
override fun getContentDetails(url: String): IPlatformContentDetails {
|
||||||
|
val uri = Uri.parse(url);
|
||||||
|
|
||||||
|
if("audio" in uri.pathSegments) {
|
||||||
|
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||||
|
}
|
||||||
|
else if("video" in uri.pathSegments) {
|
||||||
|
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||||
|
}
|
||||||
|
else if(uri.toString().contains("com.android.externalstorage.documents")) {
|
||||||
|
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
|
||||||
|
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||||
|
else
|
||||||
|
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
throw Exception("Unknown content url [${url}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSearchCapabilities(): ResultCapabilities
|
||||||
|
= ResultCapabilities();
|
||||||
|
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||||
|
return EmptyPager(); //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSearchChannelContentsCapabilities(): ResultCapabilities
|
||||||
|
= ResultCapabilities();
|
||||||
|
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||||
|
return EmptyPager(); //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchChannels(query: String): IPager<PlatformAuthorLink> {
|
||||||
|
return EmptyPager(); //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||||
|
return EmptyPager(); //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChannelUrl(url: String): Boolean {
|
||||||
|
return false //TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChannel(channelUrl: String): IPlatformChannel {
|
||||||
|
throw NotImplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChannelCapabilities(): ResultCapabilities
|
||||||
|
= ResultCapabilities();
|
||||||
|
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||||
|
return EmptyPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
|
||||||
|
return EmptyPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPeekChannelTypes(): List<String> = listOf();
|
||||||
|
|
||||||
|
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent>
|
||||||
|
= listOf();
|
||||||
|
|
||||||
|
override fun getShorts(): IPager<IPlatformVideo> = EmptyPager();
|
||||||
|
|
||||||
|
override fun searchSuggestions(query: String): Array<String> = arrayOf();
|
||||||
|
|
||||||
|
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String?
|
||||||
|
= null;
|
||||||
|
|
||||||
|
override fun getContentChapters(url: String): List<IChapter>
|
||||||
|
= listOf();
|
||||||
|
|
||||||
|
override fun getPlaybackTracker(url: String): IPlaybackTracker?
|
||||||
|
= null;
|
||||||
|
|
||||||
|
override fun getContentRecommendations(url: String): IPager<IPlatformContent>?
|
||||||
|
= null;
|
||||||
|
|
||||||
|
override fun getComments(url: String): IPager<IPlatformComment>
|
||||||
|
= EmptyPager();
|
||||||
|
|
||||||
|
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>
|
||||||
|
= EmptyPager();
|
||||||
|
|
||||||
|
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
|
||||||
|
= null;
|
||||||
|
|
||||||
|
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
|
||||||
|
= null;
|
||||||
|
|
||||||
|
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent>
|
||||||
|
= throw NotImplementedError();
|
||||||
|
|
||||||
|
override fun isPlaylistUrl(url: String): Boolean = false;
|
||||||
|
|
||||||
|
override fun getPlaylist(url: String): IPlatformPlaylistDetails
|
||||||
|
= throw NotImplementedError();
|
||||||
|
override fun getUserPlaylists(): Array<String> = throw NotImplementedError();
|
||||||
|
override fun getUserSubscriptions(): Array<String> = throw NotImplementedError();
|
||||||
|
override fun getUserHistory(): IPager<IPlatformContent> = throw NotImplementedError();
|
||||||
|
override fun isClaimTypeSupported(claimType: Int): Boolean = false;
|
||||||
}
|
}
|
||||||
+2
-2
@@ -23,10 +23,10 @@ class LocalAudioContentSource : IAudioSource {
|
|||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
|
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -20,14 +20,17 @@ class LocalVideoContentSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = false;
|
||||||
|
|
||||||
var contentUrl: String;
|
var contentUrl: String;
|
||||||
|
|
||||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
constructor(contentUrl: String, mime: String, name: String? = null, duration: Long = 0) {
|
||||||
this.name = name ?: "File";
|
this.name = name ?: "File";
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
container = mime;
|
container = mime;
|
||||||
duration = 0;
|
this.duration = duration;
|
||||||
this.contentUrl = contentUrl;
|
this.contentUrl = contentUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
@@ -20,6 +20,9 @@ class LocalVideoFileSource: IVideoSource {
|
|||||||
override val duration: Long;
|
override val duration: Long;
|
||||||
override val priority: Boolean = false;
|
override val priority: Boolean = false;
|
||||||
|
|
||||||
|
override val language: String? = null;
|
||||||
|
override val original: Boolean? = null;
|
||||||
|
|
||||||
var file: File;
|
var file: File;
|
||||||
|
|
||||||
constructor(file: File) {
|
constructor(file: File) {
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
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 java.net.InetAddress
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://nto.github.io/AirPlay
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = false;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private val _client = ManagedHttpClient();
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(FCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
if (resumePosition > 0.0) {
|
|
||||||
val pos = resumePosition / duration;
|
|
||||||
Logger.i(TAG, "resumePosition: $resumePosition, duration: ${duration}, pos: $pos")
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: $pos");
|
|
||||||
} else {
|
|
||||||
post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (speed != null) {
|
|
||||||
changeSpeed(speed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("scrub?position=${timeSeconds}");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = true;
|
|
||||||
post("rate?value=1.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = false;
|
|
||||||
post("rate?value=0.000000");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
post("stop");
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val connectedSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (connectedSocket == null) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
connectedSocket.close();
|
|
||||||
_sessionId = UUID.randomUUID().toString();
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to AirPlay device.", e)
|
|
||||||
delay(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val progressInfo = getProgress();
|
|
||||||
if (progressInfo == null) {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Logger.i(TAG, "Failed to retrieve progress from AirPlay device.");
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTED;
|
|
||||||
|
|
||||||
val progressIndex = progressInfo.lowercase().indexOf("position: ");
|
|
||||||
if (progressIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val progress = progressInfo.substring(progressIndex + "position: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setTime(progress);
|
|
||||||
|
|
||||||
val durationIndex = progressInfo.lowercase().indexOf("duration: ");
|
|
||||||
if (durationIndex == -1) {
|
|
||||||
delay(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = progressInfo.substring(durationIndex + "duration: ".length).toDoubleOrNull() ?: continue;
|
|
||||||
setDuration(duration);
|
|
||||||
delay(1000);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get server info from AirPlay device.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to setup AirPlay device connection.", e)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
_scopeIO = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
setSpeed(speed)
|
|
||||||
post("rate?value=$speed")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getProgress(): String? {
|
|
||||||
val info = get("scrub");
|
|
||||||
Logger.i(TAG, "Progress: ${info ?: "null"}");
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPlaybackInfo(): String? {
|
|
||||||
val playbackInfo = get("playback-info");
|
|
||||||
Logger.i(TAG, "Playback info: ${playbackInfo ?: "null"}");
|
|
||||||
return playbackInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getServerInfo(): String? {
|
|
||||||
val serverInfo = get("server-info");
|
|
||||||
Logger.i(TAG, "Server info: ${serverInfo ?: "null"}");
|
|
||||||
return serverInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url");
|
|
||||||
val response = _client.post(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun post(path: String, contentType: String, body: String): Boolean {
|
|
||||||
try {
|
|
||||||
val sessionId = _sessionId ?: return false;
|
|
||||||
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId,
|
|
||||||
"Content-Type" to contentType
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "POST $url:\n$body");
|
|
||||||
val response = _client.post(url, body, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to POST $path $body");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(path: String): String? {
|
|
||||||
val sessionId = _sessionId ?: return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val headers = hashMapOf(
|
|
||||||
"X-Apple-Device-ID" to "0xdc2b61a0ce79",
|
|
||||||
"Content-Length" to "0",
|
|
||||||
"User-Agent" to "MediaControl/1.0",
|
|
||||||
"X-Apple-Session-ID" to sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
val url = "http://${usedRemoteAddress}:${port}/${path}";
|
|
||||||
|
|
||||||
Logger.i(TAG, "GET $url");
|
|
||||||
val response = _client.get(url, headers);
|
|
||||||
if (!response.isOk) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.body == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.body.string();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to GET $path");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "AirPlayCastingDevice";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +1,217 @@
|
|||||||
package com.futo.platformplayer.casting
|
package com.futo.platformplayer.casting
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
import org.fcast.sender_sdk.ApplicationInfo
|
||||||
|
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice
|
||||||
|
import org.fcast.sender_sdk.KeyEvent
|
||||||
|
import org.fcast.sender_sdk.MediaEvent
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import org.fcast.sender_sdk.PlaybackState
|
||||||
|
import org.fcast.sender_sdk.Source
|
||||||
|
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||||
|
import org.fcast.sender_sdk.DeviceConnectionState
|
||||||
|
import org.fcast.sender_sdk.DeviceFeature
|
||||||
|
import org.fcast.sender_sdk.EventSubscription
|
||||||
|
import org.fcast.sender_sdk.IpAddr
|
||||||
|
import org.fcast.sender_sdk.LoadRequest
|
||||||
|
import org.fcast.sender_sdk.MediaItemEventType
|
||||||
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
|
|
||||||
abstract class CastingDevice {
|
enum class CastConnectionState {
|
||||||
abstract val isReady: Boolean
|
DISCONNECTED,
|
||||||
abstract val usedRemoteAddress: InetAddress?
|
CONNECTING,
|
||||||
abstract val localAddress: InetAddress?
|
CONNECTED
|
||||||
abstract val name: String?
|
}
|
||||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
|
||||||
abstract val onPlayChanged: Event1<Boolean>
|
|
||||||
abstract val onTimeChanged: Event1<Double>
|
|
||||||
abstract val onDurationChanged: Event1<Double>
|
|
||||||
abstract val onVolumeChanged: Event1<Double>
|
|
||||||
abstract val onSpeedChanged: Event1<Double>
|
|
||||||
abstract var connectionState: CastConnectionState
|
|
||||||
abstract val protocolType: CastProtocolType
|
|
||||||
abstract var isPlaying: Boolean
|
|
||||||
abstract val expectedCurrentTime: Double
|
|
||||||
abstract var speed: Double
|
|
||||||
abstract var time: Double
|
|
||||||
abstract var duration: Double
|
|
||||||
abstract var volume: Double
|
|
||||||
abstract fun canSetVolume(): Boolean
|
|
||||||
abstract fun canSetSpeed(): Boolean
|
|
||||||
|
|
||||||
@Throws
|
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||||
abstract fun resumePlayback()
|
enum class CastProtocolType {
|
||||||
|
CHROMECAST,
|
||||||
|
AIRPLAY,
|
||||||
|
FCAST;
|
||||||
|
|
||||||
@Throws
|
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||||
abstract fun pausePlayback()
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||||
|
|
||||||
@Throws
|
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||||
abstract fun stopPlayback()
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||||
abstract fun seekTo(timeSeconds: Double)
|
val name = decoder.decodeString()
|
||||||
|
return when (name) {
|
||||||
|
"FASTCAST" -> FCAST // Handle the renamed case
|
||||||
|
else -> CastProtocolType.valueOf(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||||
abstract fun changeVolume(timeSeconds: Double)
|
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||||
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@Throws
|
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||||
abstract fun changeSpeed(speed: Double)
|
byteArrayOf(
|
||||||
|
addr.o1.toByte(),
|
||||||
|
addr.o2.toByte(),
|
||||||
|
addr.o3.toByte(),
|
||||||
|
addr.o4.toByte(),
|
||||||
|
addr.o5.toByte(),
|
||||||
|
addr.o6.toByte(),
|
||||||
|
addr.o7.toByte(),
|
||||||
|
addr.o8.toByte(),
|
||||||
|
addr.o9.toByte(),
|
||||||
|
addr.o10.toByte(),
|
||||||
|
addr.o11.toByte(),
|
||||||
|
addr.o12.toByte(),
|
||||||
|
addr.o13.toByte(),
|
||||||
|
addr.o14.toByte(),
|
||||||
|
addr.o15.toByte(),
|
||||||
|
addr.o16.toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Throws
|
class CastingDevice(val device: RsCastingDevice) {
|
||||||
abstract fun connect()
|
class EventHandler : RsDeviceEventHandler {
|
||||||
|
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||||
|
var onPlayChanged = Event1<Boolean>()
|
||||||
|
var onTimeChanged = Event1<Double>()
|
||||||
|
var onDurationChanged = Event1<Double>()
|
||||||
|
var onVolumeChanged = Event1<Double>()
|
||||||
|
var onSpeedChanged = Event1<Double>()
|
||||||
|
var onMediaItemEnd = Event0()
|
||||||
|
|
||||||
@Throws
|
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||||
abstract fun disconnect()
|
onConnectionStateChanged.emit(state)
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
}
|
||||||
abstract fun getAddresses(): List<InetAddress>
|
|
||||||
|
|
||||||
@Throws
|
override fun volumeChanged(volume: Double) {
|
||||||
abstract fun loadVideo(
|
onVolumeChanged.emit(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun timeChanged(time: Double) {
|
||||||
|
onTimeChanged.emit(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackStateChanged(state: PlaybackState) {
|
||||||
|
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun durationChanged(duration: Double) {
|
||||||
|
onDurationChanged.emit(duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun speedChanged(speed: Double) {
|
||||||
|
onSpeedChanged.emit(speed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sourceChanged(source: Source) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun keyEvent(event: KeyEvent) {
|
||||||
|
// Unreachable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mediaEvent(event: MediaEvent) {
|
||||||
|
if (event.type == MediaItemEventType.END) {
|
||||||
|
onMediaItemEnd.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun playbackError(message: String) {
|
||||||
|
Logger.e(TAG, "Playback error: $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val eventHandler = EventHandler()
|
||||||
|
val isReady: Boolean
|
||||||
|
get() = device.isReady()
|
||||||
|
val name: String
|
||||||
|
get() = device.name()
|
||||||
|
var usedRemoteAddress: InetAddress? = null
|
||||||
|
var localAddress: InetAddress? = null
|
||||||
|
fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||||
|
fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||||
|
|
||||||
|
val onConnectionStateChanged =
|
||||||
|
Event1<CastConnectionState>()
|
||||||
|
val onPlayChanged: Event1<Boolean>
|
||||||
|
get() = eventHandler.onPlayChanged
|
||||||
|
val onTimeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onTimeChanged
|
||||||
|
val onDurationChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onDurationChanged
|
||||||
|
val onVolumeChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onVolumeChanged
|
||||||
|
val onSpeedChanged: Event1<Double>
|
||||||
|
get() = eventHandler.onSpeedChanged
|
||||||
|
val onMediaItemEnd: Event0
|
||||||
|
get() = eventHandler.onMediaItemEnd
|
||||||
|
|
||||||
|
fun resumePlayback() = device.resumePlayback()
|
||||||
|
fun pausePlayback() = device.pausePlayback()
|
||||||
|
fun stopPlayback() = device.stopPlayback()
|
||||||
|
fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||||
|
fun changeVolume(newVolume: Double) {
|
||||||
|
device.changeVolume(newVolume)
|
||||||
|
volume = newVolume
|
||||||
|
}
|
||||||
|
fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||||
|
fun connect() = device.connect(
|
||||||
|
ApplicationInfo(
|
||||||
|
"Grayjay Android",
|
||||||
|
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
),
|
||||||
|
eventHandler,
|
||||||
|
1000.toULong()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disconnect() = device.disconnect()
|
||||||
|
|
||||||
|
fun getDeviceInfo(): CastingDeviceInfo {
|
||||||
|
val info = device.getDeviceInfo()
|
||||||
|
return CastingDeviceInfo(
|
||||||
|
info.name,
|
||||||
|
when (info.protocol) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
},
|
||||||
|
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||||
|
port = info.port.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||||
|
ipAddrToInetAddress(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadVideo(
|
||||||
streamType: String,
|
streamType: String,
|
||||||
contentType: String,
|
contentType: String,
|
||||||
contentId: String,
|
contentId: String,
|
||||||
@@ -62,18 +219,107 @@ abstract class CastingDevice {
|
|||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Video(
|
||||||
|
contentType = contentType,
|
||||||
|
url = contentId,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@Throws
|
fun loadContent(
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
contentType: String,
|
||||||
content: String,
|
content: String,
|
||||||
resumePosition: Double,
|
resumePosition: Double,
|
||||||
duration: Double,
|
duration: Double,
|
||||||
speed: Double?,
|
speed: Double?,
|
||||||
metadata: Metadata?
|
metadata: Metadata?
|
||||||
|
) = device.load(
|
||||||
|
LoadRequest.Content(
|
||||||
|
contentType = contentType,
|
||||||
|
content = content,
|
||||||
|
resumePosition = resumePosition,
|
||||||
|
speed = speed,
|
||||||
|
volume = volume,
|
||||||
|
metadata = metadata,
|
||||||
|
requestHeaders = null,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
abstract fun ensureThreadStarted()
|
var connectionState = CastConnectionState.DISCONNECTED
|
||||||
}
|
val protocolType: CastProtocolType
|
||||||
|
get() = when (device.castingProtocol()) {
|
||||||
|
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||||
|
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||||
|
}
|
||||||
|
var volume: Double = 1.0
|
||||||
|
var duration: Double = 0.0
|
||||||
|
private var lastTimeChangeTime_ms: Long = 0
|
||||||
|
var time: Double = 0.0
|
||||||
|
var speed: Double = 0.0
|
||||||
|
var isPlaying: Boolean = false
|
||||||
|
|
||||||
|
val expectedCurrentTime: Double
|
||||||
|
get() {
|
||||||
|
val diff =
|
||||||
|
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||||
|
return time + diff
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||||
|
when (newState) {
|
||||||
|
is DeviceConnectionState.Connected -> {
|
||||||
|
if (device.supportsFeature(DeviceFeature.MEDIA_EVENT_SUBSCRIPTION)) {
|
||||||
|
try {
|
||||||
|
device.subscribeEvent(EventSubscription.MediaItemEnd)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to subscribe to MediaItemEnd events: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||||
|
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||||
|
connectionState = CastConnectionState.CONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||||
|
connectionState = CastConnectionState.CONNECTING
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceConnectionState.Disconnected -> {
|
||||||
|
connectionState = CastConnectionState.DISCONNECTED
|
||||||
|
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newState == DeviceConnectionState.Disconnected) {
|
||||||
|
try {
|
||||||
|
Logger.i(TAG, "Stopping device")
|
||||||
|
device.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to stop device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||||
|
eventHandler.onTimeChanged.subscribe {
|
||||||
|
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||||
|
time = it
|
||||||
|
}
|
||||||
|
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||||
|
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||||
|
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureThreadStarted() {}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = "CastingDeviceExp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import org.fcast.sender_sdk.ApplicationInfo
|
|
||||||
import org.fcast.sender_sdk.GenericKeyEvent
|
|
||||||
import org.fcast.sender_sdk.GenericMediaEvent
|
|
||||||
import org.fcast.sender_sdk.PlaybackState
|
|
||||||
import org.fcast.sender_sdk.Source
|
|
||||||
import java.net.InetAddress
|
|
||||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
|
||||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
|
||||||
import org.fcast.sender_sdk.DeviceConnectionState
|
|
||||||
import org.fcast.sender_sdk.DeviceFeature
|
|
||||||
import org.fcast.sender_sdk.IpAddr
|
|
||||||
import org.fcast.sender_sdk.LoadRequest
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.Inet6Address
|
|
||||||
|
|
||||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
|
||||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
|
||||||
byteArrayOf(
|
|
||||||
addr.o1.toByte(),
|
|
||||||
addr.o2.toByte(),
|
|
||||||
addr.o3.toByte(),
|
|
||||||
addr.o4.toByte(),
|
|
||||||
addr.o5.toByte(),
|
|
||||||
addr.o6.toByte(),
|
|
||||||
addr.o7.toByte(),
|
|
||||||
addr.o8.toByte(),
|
|
||||||
addr.o9.toByte(),
|
|
||||||
addr.o10.toByte(),
|
|
||||||
addr.o11.toByte(),
|
|
||||||
addr.o12.toByte(),
|
|
||||||
addr.o13.toByte(),
|
|
||||||
addr.o14.toByte(),
|
|
||||||
addr.o15.toByte(),
|
|
||||||
addr.o16.toByte()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
|
||||||
class EventHandler : RsDeviceEventHandler {
|
|
||||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>()
|
|
||||||
var onTimeChanged = Event1<Double>()
|
|
||||||
var onDurationChanged = Event1<Double>()
|
|
||||||
var onVolumeChanged = Event1<Double>()
|
|
||||||
var onSpeedChanged = Event1<Double>()
|
|
||||||
|
|
||||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
|
||||||
onConnectionStateChanged.emit(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun volumeChanged(volume: Double) {
|
|
||||||
onVolumeChanged.emit(volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun timeChanged(time: Double) {
|
|
||||||
onTimeChanged.emit(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackStateChanged(state: PlaybackState) {
|
|
||||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun durationChanged(duration: Double) {
|
|
||||||
onDurationChanged.emit(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun speedChanged(speed: Double) {
|
|
||||||
onSpeedChanged.emit(speed)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sourceChanged(source: Source) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun keyEvent(event: GenericKeyEvent) {
|
|
||||||
// Unreachable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun mediaEvent(event: GenericMediaEvent) {
|
|
||||||
// Unreachable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun playbackError(message: String) {
|
|
||||||
Logger.e(TAG, "Playback error: $message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val eventHandler = EventHandler()
|
|
||||||
override val isReady: Boolean
|
|
||||||
get() = device.isReady()
|
|
||||||
override val name: String
|
|
||||||
get() = device.name()
|
|
||||||
override var usedRemoteAddress: InetAddress? = null
|
|
||||||
override var localAddress: InetAddress? = null
|
|
||||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
|
||||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
|
||||||
|
|
||||||
override val onConnectionStateChanged =
|
|
||||||
Event1<CastConnectionState>()
|
|
||||||
override val onPlayChanged: Event1<Boolean>
|
|
||||||
get() = eventHandler.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double>
|
|
||||||
get() = eventHandler.onSpeedChanged
|
|
||||||
|
|
||||||
override fun resumePlayback() = device.resumePlayback()
|
|
||||||
override fun pausePlayback() = device.pausePlayback()
|
|
||||||
override fun stopPlayback() = device.stopPlayback()
|
|
||||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
|
||||||
override fun changeVolume(newVolume: Double) {
|
|
||||||
device.changeVolume(newVolume)
|
|
||||||
volume = newVolume
|
|
||||||
}
|
|
||||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
|
||||||
override fun connect() = device.connect(
|
|
||||||
ApplicationInfo(
|
|
||||||
"Grayjay Android",
|
|
||||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
|
||||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
|
||||||
),
|
|
||||||
eventHandler,
|
|
||||||
1000.toULong()
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun disconnect() = device.disconnect()
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
val info = device.getDeviceInfo()
|
|
||||||
return CastingDeviceInfo(
|
|
||||||
info.name,
|
|
||||||
when (info.protocol) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
},
|
|
||||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
|
||||||
port = info.port.toInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
|
||||||
ipAddrToInetAddress(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Video(
|
|
||||||
contentType = contentType,
|
|
||||||
url = contentId,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = device.load(
|
|
||||||
LoadRequest.Content(
|
|
||||||
contentType = contentType,
|
|
||||||
content = content,
|
|
||||||
resumePosition = resumePosition,
|
|
||||||
speed = speed,
|
|
||||||
volume = volume,
|
|
||||||
metadata = metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
override var connectionState = CastConnectionState.DISCONNECTED
|
|
||||||
override val protocolType: CastProtocolType
|
|
||||||
get() = when (device.castingProtocol()) {
|
|
||||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
|
||||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
|
||||||
}
|
|
||||||
override var volume: Double = 1.0
|
|
||||||
override var duration: Double = 0.0
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
override var time: Double = 0.0
|
|
||||||
override var speed: Double = 0.0
|
|
||||||
override var isPlaying: Boolean = false
|
|
||||||
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
|
||||||
when (newState) {
|
|
||||||
is DeviceConnectionState.Connected -> {
|
|
||||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
|
||||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
|
||||||
connectionState = CastConnectionState.CONNECTING
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceConnectionState.Disconnected -> {
|
|
||||||
connectionState = CastConnectionState.CONNECTING
|
|
||||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newState == DeviceConnectionState.Disconnected) {
|
|
||||||
try {
|
|
||||||
Logger.i(TAG, "Stopping device")
|
|
||||||
device.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to stop device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
|
||||||
eventHandler.onTimeChanged.subscribe {
|
|
||||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
|
||||||
time = it
|
|
||||||
}
|
|
||||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
|
||||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
|
||||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() {}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "CastingDeviceExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.serialization.KSerializer
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
||||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
|
||||||
import kotlinx.serialization.encoding.Decoder
|
|
||||||
import kotlinx.serialization.encoding.Encoder
|
|
||||||
import org.fcast.sender_sdk.Metadata
|
|
||||||
import java.net.InetAddress
|
|
||||||
|
|
||||||
enum class CastConnectionState {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
|
||||||
enum class CastProtocolType {
|
|
||||||
CHROMECAST,
|
|
||||||
AIRPLAY,
|
|
||||||
FCAST;
|
|
||||||
|
|
||||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
|
||||||
override val descriptor: SerialDescriptor =
|
|
||||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
|
||||||
|
|
||||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
|
||||||
encoder.encodeString(value.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
|
||||||
val name = decoder.decodeString()
|
|
||||||
return when (name) {
|
|
||||||
"FASTCAST" -> FCAST // Handle the renamed case
|
|
||||||
else -> CastProtocolType.valueOf(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class CastingDeviceLegacy {
|
|
||||||
abstract val protocol: CastProtocolType;
|
|
||||||
abstract val isReady: Boolean;
|
|
||||||
abstract var usedRemoteAddress: InetAddress?;
|
|
||||||
abstract var localAddress: InetAddress?;
|
|
||||||
abstract val canSetVolume: Boolean;
|
|
||||||
abstract val canSetSpeed: Boolean;
|
|
||||||
|
|
||||||
var name: String? = null;
|
|
||||||
var isPlaying: Boolean = false
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
if (changed) {
|
|
||||||
onPlayChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private var lastTimeChangeTime_ms: Long = 0
|
|
||||||
var time: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
|
||||||
time = value
|
|
||||||
lastTimeChangeTime_ms = changeTime_ms
|
|
||||||
onTimeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastDurationChangeTime_ms: Long = 0
|
|
||||||
var duration: Double = 0.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
|
||||||
duration = value
|
|
||||||
lastDurationChangeTime_ms = changeTime_ms
|
|
||||||
onDurationChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastVolumeChangeTime_ms: Long = 0
|
|
||||||
var volume: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
|
||||||
volume = value
|
|
||||||
lastVolumeChangeTime_ms = changeTime_ms
|
|
||||||
onVolumeChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastSpeedChangeTime_ms: Long = 0
|
|
||||||
var speed: Double = 1.0
|
|
||||||
private set
|
|
||||||
|
|
||||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
|
||||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
|
||||||
speed = value
|
|
||||||
lastSpeedChangeTime_ms = changeTime_ms
|
|
||||||
onSpeedChanged.emit(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val expectedCurrentTime: Double
|
|
||||||
get() {
|
|
||||||
val diff =
|
|
||||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
|
||||||
return time + diff;
|
|
||||||
};
|
|
||||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
|
||||||
set(value) {
|
|
||||||
val changed = value != field;
|
|
||||||
field = value;
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
onConnectionStateChanged.emit(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
|
||||||
var onPlayChanged = Event1<Boolean>();
|
|
||||||
var onTimeChanged = Event1<Double>();
|
|
||||||
var onDurationChanged = Event1<Double>();
|
|
||||||
var onVolumeChanged = Event1<Double>();
|
|
||||||
var onSpeedChanged = Event1<Double>();
|
|
||||||
|
|
||||||
abstract fun stopCasting();
|
|
||||||
|
|
||||||
abstract fun seekVideo(timeSeconds: Double);
|
|
||||||
abstract fun stopVideo();
|
|
||||||
abstract fun pauseVideo();
|
|
||||||
abstract fun resumeVideo();
|
|
||||||
abstract fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
abstract fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?
|
|
||||||
);
|
|
||||||
|
|
||||||
open fun changeVolume(volume: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun changeSpeed(speed: Double) {
|
|
||||||
throw NotImplementedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract fun start();
|
|
||||||
abstract fun stop();
|
|
||||||
|
|
||||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
|
||||||
|
|
||||||
abstract fun getAddresses(): List<InetAddress>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
|
||||||
override val isReady: Boolean get() = inner.isReady
|
|
||||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
|
||||||
override val localAddress: InetAddress? get() = inner.localAddress
|
|
||||||
override val name: String? get() = inner.name
|
|
||||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
|
||||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
|
||||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
|
||||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
|
||||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
|
||||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
|
||||||
override var connectionState: CastConnectionState
|
|
||||||
get() = inner.connectionState
|
|
||||||
set(_) = Unit
|
|
||||||
override val protocolType: CastProtocolType get() = inner.protocol
|
|
||||||
override var isPlaying: Boolean
|
|
||||||
get() = inner.isPlaying
|
|
||||||
set(_) = Unit
|
|
||||||
override val expectedCurrentTime: Double
|
|
||||||
get() = inner.expectedCurrentTime
|
|
||||||
override var speed: Double
|
|
||||||
get() = inner.speed
|
|
||||||
set(_) = Unit
|
|
||||||
override var time: Double
|
|
||||||
get() = inner.time
|
|
||||||
set(_) = Unit
|
|
||||||
override var duration: Double
|
|
||||||
get() = inner.duration
|
|
||||||
set(_) = Unit
|
|
||||||
override var volume: Double
|
|
||||||
get() = inner.volume
|
|
||||||
set(_) = Unit
|
|
||||||
|
|
||||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
|
||||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
|
||||||
override fun resumePlayback() = inner.resumeVideo()
|
|
||||||
override fun pausePlayback() = inner.pauseVideo()
|
|
||||||
override fun stopPlayback() = inner.stopVideo()
|
|
||||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
|
||||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
|
||||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
|
||||||
override fun connect() = inner.start()
|
|
||||||
override fun disconnect() = inner.stop()
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
|
||||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
|
||||||
override fun loadVideo(
|
|
||||||
streamType: String,
|
|
||||||
contentType: String,
|
|
||||||
contentId: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun loadContent(
|
|
||||||
contentType: String,
|
|
||||||
content: String,
|
|
||||||
resumePosition: Double,
|
|
||||||
duration: Double,
|
|
||||||
speed: Double?,
|
|
||||||
metadata: Metadata?
|
|
||||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
|
||||||
|
|
||||||
override fun ensureThreadStarted() = when (inner) {
|
|
||||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
|
||||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.protos.ChromeCast
|
|
||||||
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
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.DataOutputStream
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManager
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _streamType: String? = null;
|
|
||||||
private var _contentType: String? = null;
|
|
||||||
private var _contentId: String? = null;
|
|
||||||
|
|
||||||
private var _socket: SSLSocket? = null;
|
|
||||||
private var _outputStream: DataOutputStream? = null;
|
|
||||||
private var _outputStreamLock = Object();
|
|
||||||
private var _inputStream: DataInputStream? = null;
|
|
||||||
private var _inputStreamLock = Object();
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _requestId = 1;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _sessionId: String? = null;
|
|
||||||
private var _transportId: String? = null;
|
|
||||||
private var _launching = false;
|
|
||||||
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;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
_streamType = streamType;
|
|
||||||
_contentType = contentType;
|
|
||||||
_contentId = contentId;
|
|
||||||
|
|
||||||
playVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
//TODO: Can maybe be implemented by sending data:contentType,base64...
|
|
||||||
throw NotImplementedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun connectMediaChannel(transportId: String) {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMediaStatus() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "GET_STATUS");
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun playVideo() {
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val contentId = _contentId ?: return;
|
|
||||||
val streamType = _streamType ?: return;
|
|
||||||
val contentType = _contentType ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "LOAD");
|
|
||||||
|
|
||||||
val mediaObject = JSONObject();
|
|
||||||
mediaObject.put("contentId", contentId);
|
|
||||||
mediaObject.put("streamType", streamType);
|
|
||||||
mediaObject.put("contentType", contentType);
|
|
||||||
|
|
||||||
if (time > 0.0) {
|
|
||||||
val seekTime = time;
|
|
||||||
loadObject.put("currentTime", seekTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadObject.put("media", mediaObject);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
|
|
||||||
|
|
||||||
//TODO: This replace is necessary to get rid of backward slashes added by the JSON Object serializer
|
|
||||||
val json = loadObject.toString().replace("\\/","/");
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume)
|
|
||||||
val setVolumeObject = JSONObject();
|
|
||||||
setVolumeObject.put("type", "SET_VOLUME");
|
|
||||||
|
|
||||||
val volumeObject = JSONObject();
|
|
||||||
volumeObject.put("level", volume)
|
|
||||||
setVolumeObject.put("volume", volumeObject);
|
|
||||||
|
|
||||||
setVolumeObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", setVolumeObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "SEEK");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
loadObject.put("currentTime", timeSeconds);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PLAY");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "PAUSE");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val transportId = _transportId ?: return;
|
|
||||||
val mediaSessionId = _mediaSessionId ?: return;
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
|
|
||||||
val loadObject = JSONObject();
|
|
||||||
loadObject.put("type", "STOP");
|
|
||||||
loadObject.put("mediaSessionId", mediaSessionId);
|
|
||||||
loadObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", transportId, "urn:x-cast:com.google.cast.media", loadObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun launchPlayer() {
|
|
||||||
if (invokeInIOScopeIfRequired(::launchPlayer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "LAUNCH");
|
|
||||||
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() {
|
|
||||||
if (invokeInIOScopeIfRequired(::getStatus)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "GET_STATUS");
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch { action(); }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionId = _sessionId;
|
|
||||||
if (sessionId != null) {
|
|
||||||
val launchObject = JSONObject();
|
|
||||||
launchObject.put("type", "STOP");
|
|
||||||
launchObject.put("sessionId", sessionId);
|
|
||||||
launchObject.put("requestId", _requestId++);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
|
||||||
|
|
||||||
_contentId = null;
|
|
||||||
_contentType = null;
|
|
||||||
_streamType = null;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_transportId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_autoLaunchEnabled = true
|
|
||||||
_started = true;
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
_launching = true;
|
|
||||||
|
|
||||||
ensureThreadsStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadsStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive) {
|
|
||||||
Log.i(TAG, "Restarting threads because one of the threads has died")
|
|
||||||
|
|
||||||
_scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress;
|
|
||||||
localAddress = connectedSocket.localAddress;
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to ChromeCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sslContext = SSLContext.getInstance("TLS");
|
|
||||||
sslContext.init(null, trustAllCerts, null);
|
|
||||||
|
|
||||||
val factory = sslContext.socketFactory;
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
_sessionId = null;
|
|
||||||
_launchRetries = 0
|
|
||||||
_mediaSessionId = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "Connecting to Chromecast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.")
|
|
||||||
_socket = factory.createSocket(connectedSocket, connectedSocket.inetAddress.hostAddress, connectedSocket.port, true) as SSLSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.")
|
|
||||||
val s = Socket().apply { this.connect(address, 2000) }
|
|
||||||
_socket = factory.createSocket(s, s.inetAddress.hostAddress, s.port, true) as SSLSocket
|
|
||||||
}
|
|
||||||
|
|
||||||
_socket?.startHandshake();
|
|
||||||
Logger.i(TAG, "Successfully connected to Chromecast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
try {
|
|
||||||
_outputStream = DataOutputStream(_socket?.outputStream);
|
|
||||||
_inputStream = DataInputStream(_socket?.inputStream);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to authenticate to Chromecast.", e);
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Failed to connect to Chromecast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress;
|
|
||||||
|
|
||||||
try {
|
|
||||||
val connectObject = JSONObject();
|
|
||||||
connectObject.put("type", "CONNECT");
|
|
||||||
connectObject.put("connType", 0);
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.connection", connectObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.i(TAG, "Failed to send connect message to Chromecast.", e);
|
|
||||||
_socket?.close();
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatus();
|
|
||||||
|
|
||||||
val buffer = ByteArray(409600);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
|
|
||||||
val message = synchronized(_inputStreamLock)
|
|
||||||
{
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
val b1 = inputStream.readUnsignedByte();
|
|
||||||
val b2 = inputStream.readUnsignedByte();
|
|
||||||
val b3 = inputStream.readUnsignedByte();
|
|
||||||
val b4 = inputStream.readUnsignedByte();
|
|
||||||
val size =
|
|
||||||
((b1.toLong() shl 24) or (b2.toLong() shl 16) or (b3.toLong() shl 8) or b4.toLong()).toInt();
|
|
||||||
if (size > buffer.size) {
|
|
||||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
|
||||||
inputStream.skip(size.toLong());
|
|
||||||
return@synchronized null
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
inputStream.read(buffer, 0, size);
|
|
||||||
|
|
||||||
//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 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) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_socket?.close();
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() };
|
|
||||||
|
|
||||||
//Start ping loop
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
|
|
||||||
val pingObject = JSONObject();
|
|
||||||
pingObject.put("type", "PING");
|
|
||||||
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.tp.heartbeat", pingObject.toString());
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to send ping.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.sleep(5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped ping loop.");
|
|
||||||
}.apply { start() };
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Threads still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendChannelMessage(sourceId: String, destinationId: String, namespace: String, json: String) {
|
|
||||||
try {
|
|
||||||
val castMessage = ChromeCast.CastMessage.newBuilder()
|
|
||||||
.setProtocolVersion(ChromeCast.CastMessage.ProtocolVersion.CASTV2_1_0)
|
|
||||||
.setSourceId(sourceId)
|
|
||||||
.setDestinationId(destinationId)
|
|
||||||
.setNamespace(namespace)
|
|
||||||
.setPayloadType(ChromeCast.CastMessage.PayloadType.STRING)
|
|
||||||
.setPayloadUtf8(json)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
sendMessage(castMessage.toByteArray());
|
|
||||||
|
|
||||||
if (namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
|
||||||
//Log.d(TAG, "Sent channel message: $castMessage");
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(message: ChromeCast.CastMessage) {
|
|
||||||
if (message.payloadType == ChromeCast.CastMessage.PayloadType.STRING) {
|
|
||||||
val jsonObject = JSONObject(message.payloadUtf8);
|
|
||||||
val type = jsonObject.getString("type");
|
|
||||||
if (type == "RECEIVER_STATUS") {
|
|
||||||
val status = jsonObject.getJSONObject("status");
|
|
||||||
|
|
||||||
var sessionIsRunning = false;
|
|
||||||
if (status.has("applications")) {
|
|
||||||
val applications = status.getJSONArray("applications");
|
|
||||||
|
|
||||||
for (i in 0 until applications.length()) {
|
|
||||||
val applicationUpdate = applications.getJSONObject(i);
|
|
||||||
|
|
||||||
val appId = applicationUpdate.getString("appId");
|
|
||||||
Logger.i(TAG, "Status update received appId (appId: $appId)");
|
|
||||||
|
|
||||||
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);
|
|
||||||
Logger.i(TAG, "Connected to media channel $transportId");
|
|
||||||
_transportId = transportId;
|
|
||||||
|
|
||||||
requestMediaStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionIsRunning) {
|
|
||||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
|
||||||
_sessionId = null
|
|
||||||
_mediaSessionId = null
|
|
||||||
_transportId = null
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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
|
|
||||||
_launchRetries = 0
|
|
||||||
_autoLaunchEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
val volume = status.getJSONObject("volume");
|
|
||||||
//val volumeControlType = volume.getString("controlType");
|
|
||||||
val volumeLevel = volume.getString("level").toDouble();
|
|
||||||
val volumeMuted = volume.getBoolean("muted");
|
|
||||||
//val volumeStepInterval = volume.getString("stepInterval").toFloat();
|
|
||||||
setVolume(if (volumeMuted) 0.0 else volumeLevel);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Status update received volume (level: $volumeLevel, muted: $volumeMuted)");
|
|
||||||
} else if (type == "MEDIA_STATUS") {
|
|
||||||
val statuses = jsonObject.getJSONArray("status");
|
|
||||||
for (i in 0 until statuses.length()) {
|
|
||||||
val status = statuses.getJSONObject(i);
|
|
||||||
_mediaSessionId = status.getInt("mediaSessionId");
|
|
||||||
|
|
||||||
val playerState = status.getString("playerState");
|
|
||||||
val currentTime = status.getDouble("currentTime");
|
|
||||||
if (status.has("media")) {
|
|
||||||
val media = status.getJSONObject("media")
|
|
||||||
if (media.has("duration")) {
|
|
||||||
setDuration(media.getDouble("duration"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPlaying = playerState == "PLAYING";
|
|
||||||
if (isPlaying || playerState == "PAUSED") {
|
|
||||||
setTime(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackRate = status.getInt("playbackRate");
|
|
||||||
Logger.i(TAG, "Media update received (mediaSessionId: $_mediaSessionId, playedState: $playerState, currentTime: $currentTime, playbackRate: $playbackRate)");
|
|
||||||
|
|
||||||
if (_contentType == null) {
|
|
||||||
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.");
|
|
||||||
stopCasting();
|
|
||||||
} else if (_transportId == message.sourceId) {
|
|
||||||
throw Exception("Transport id closed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw Exception("Payload type ${message.payloadType} is not implemented.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendMessage(data: ByteArray) {
|
|
||||||
val outputStream = _outputStream;
|
|
||||||
if (outputStream == null) {
|
|
||||||
Logger.w(TAG, "Failed to send ${data.size} bytes, output stream is null.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_outputStreamLock)
|
|
||||||
{
|
|
||||||
val serializedSizeBE = ByteArray(4);
|
|
||||||
serializedSizeBE[0] = (data.size shr 24 and 0xff).toByte();
|
|
||||||
serializedSizeBE[1] = (data.size shr 16 and 0xff).toByte();
|
|
||||||
serializedSizeBE[2] = (data.size shr 8 and 0xff).toByte();
|
|
||||||
serializedSizeBE[3] = (data.size and 0xff).toByte();
|
|
||||||
outputStream.write(serializedSizeBE);
|
|
||||||
outputStream.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
//Log.d(TAG, "Sent ${data.size} bytes.");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
_contentId = null
|
|
||||||
_contentType = null
|
|
||||||
_streamType = null
|
|
||||||
|
|
||||||
_retryJob?.cancel()
|
|
||||||
_retryJob = null
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_pingThread = null;
|
|
||||||
_thread = null;
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
_mediaSessionId = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.CHROMECAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "ChromecastCastingDevice";
|
|
||||||
|
|
||||||
val trustAllCerts: Array<TrustManager> = arrayOf<TrustManager>(object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> { return emptyArray(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.UIDialogs
|
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSeekMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetSpeedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastSetVolumeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVersionMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage
|
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
|
||||||
import com.futo.platformplayer.getConnectedSocket
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import com.futo.platformplayer.toHexString
|
|
||||||
import com.futo.platformplayer.toInetAddress
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.math.BigInteger
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.KeyPair
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.spec.X509EncodedKeySpec
|
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.KeyAgreement
|
|
||||||
import javax.crypto.spec.DHParameterSpec
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
enum class Opcode(val value: Byte) {
|
|
||||||
None(0),
|
|
||||||
Play(1),
|
|
||||||
Pause(2),
|
|
||||||
Resume(3),
|
|
||||||
Stop(4),
|
|
||||||
Seek(5),
|
|
||||||
PlaybackUpdate(6),
|
|
||||||
VolumeUpdate(7),
|
|
||||||
SetVolume(8),
|
|
||||||
PlaybackError(9),
|
|
||||||
SetSpeed(10),
|
|
||||||
Version(11),
|
|
||||||
Ping(12),
|
|
||||||
Pong(13);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val _map = entries.associateBy { it.value }
|
|
||||||
fun find(value: Byte): Opcode = _map[value] ?: Opcode.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FCastCastingDevice : CastingDeviceLegacy {
|
|
||||||
//See for more info: TODO
|
|
||||||
|
|
||||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
|
||||||
override val isReady: Boolean get() = name != null && addresses != null && addresses?.isNotEmpty() == true && port != 0;
|
|
||||||
override var usedRemoteAddress: InetAddress? = null;
|
|
||||||
override var localAddress: InetAddress? = null;
|
|
||||||
override val canSetVolume: Boolean get() = true;
|
|
||||||
override val canSetSpeed: Boolean get() = true;
|
|
||||||
|
|
||||||
var addresses: Array<InetAddress>? = null;
|
|
||||||
var port: Int = 0;
|
|
||||||
|
|
||||||
private var _socket: Socket? = null;
|
|
||||||
private var _outputStream: OutputStream? = null;
|
|
||||||
private var _inputStream: InputStream? = null;
|
|
||||||
private var _scopeIO: CoroutineScope? = null;
|
|
||||||
private var _started: Boolean = false;
|
|
||||||
private var _version: Long = 1;
|
|
||||||
private var _thread: Thread? = null
|
|
||||||
private var _pingThread: Thread? = null
|
|
||||||
@Volatile private var _lastPongTime = System.currentTimeMillis()
|
|
||||||
private var _outputStreamLock = Object()
|
|
||||||
|
|
||||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
|
||||||
this.name = name;
|
|
||||||
this.addresses = addresses;
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(deviceInfo: CastingDeviceInfo) : super() {
|
|
||||||
this.name = deviceInfo.name;
|
|
||||||
this.addresses = deviceInfo.addresses.map { a -> a.toInetAddress() }.filterNotNull().toTypedArray();
|
|
||||||
this.port = deviceInfo.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAddresses(): List<InetAddress> {
|
|
||||||
return addresses?.toList() ?: listOf();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadVideo(streamType, contentType, contentId, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
url = contentId,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) {
|
|
||||||
if (invokeInIOScopeIfRequired({ loadContent(contentType, content, resumePosition, duration, speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Remove this later, temporary for the transition
|
|
||||||
if (_version <= 1L) {
|
|
||||||
UIDialogs.toast("Version not received, if you are experiencing issues, try updating FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Start streaming content (contentType: $contentType, resumePosition: $resumePosition, duration: $duration, speed: $speed)");
|
|
||||||
|
|
||||||
setTime(resumePosition);
|
|
||||||
setDuration(duration);
|
|
||||||
send(Opcode.Play, FCastPlayMessage(
|
|
||||||
container = contentType,
|
|
||||||
content = content,
|
|
||||||
time = resumePosition,
|
|
||||||
speed = speed
|
|
||||||
));
|
|
||||||
|
|
||||||
setSpeed(speed ?: 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeVolume(volume: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeVolume(volume) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume);
|
|
||||||
send(Opcode.SetVolume, FCastSetVolumeMessage(volume))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun changeSpeed(speed: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSpeed(speed);
|
|
||||||
send(Opcode.SetSpeed, FCastSetSpeedMessage(speed))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekVideo(timeSeconds: Double) {
|
|
||||||
if (invokeInIOScopeIfRequired({ seekVideo(timeSeconds) })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Seek, FCastSeekMessage(
|
|
||||||
time = timeSeconds
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resumeVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::resumeVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pauseVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::pauseVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopVideo() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopVideo)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(Opcode.Stop);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invokeInIOScopeIfRequired(action: () -> Unit): Boolean {
|
|
||||||
if(Looper.getMainLooper().thread == Thread.currentThread()) {
|
|
||||||
_scopeIO?.launch {
|
|
||||||
try {
|
|
||||||
action();
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to invoke in IO scope.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stopCasting() {
|
|
||||||
if (invokeInIOScopeIfRequired(::stopCasting)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopVideo();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because stopCasting was called.")
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
if (_started) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
Logger.i(TAG, "Starting...");
|
|
||||||
|
|
||||||
ensureThreadStarted();
|
|
||||||
Logger.i(TAG, "Started.");
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureThreadStarted() {
|
|
||||||
val adrs = addresses ?: return;
|
|
||||||
|
|
||||||
val thread = _thread
|
|
||||||
val pingThread = _pingThread
|
|
||||||
if (_started && (thread == null || !thread.isAlive || pingThread == null || !pingThread.isAlive)) {
|
|
||||||
Log.i(TAG, "(Re)starting thread because the thread has died")
|
|
||||||
|
|
||||||
_scopeIO?.let {
|
|
||||||
it.cancel()
|
|
||||||
Logger.i(TAG, "Cancelled previous scopeIO because a new one is starting.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = CoroutineScope(Dispatchers.IO);
|
|
||||||
|
|
||||||
_thread = Thread {
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Log.i(TAG, "Connection thread started.")
|
|
||||||
|
|
||||||
var connectedSocket: Socket? = null
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "getConnectedSocket (adrs = [ ${adrs.joinToString(", ")} ], port = ${port}).")
|
|
||||||
|
|
||||||
val resultSocket = getConnectedSocket(adrs.toList(), port);
|
|
||||||
|
|
||||||
if (resultSocket == null) {
|
|
||||||
Log.i(TAG, "Connection failed, waiting 1 seconds.")
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "Connection succeeded.")
|
|
||||||
|
|
||||||
connectedSocket = resultSocket
|
|
||||||
usedRemoteAddress = connectedSocket.inetAddress
|
|
||||||
localAddress = connectedSocket.localAddress
|
|
||||||
break;
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to get setup initial connection to FastCast device.", e)
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val address = InetSocketAddress(usedRemoteAddress, port)
|
|
||||||
|
|
||||||
//Connection loop
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
Logger.i(TAG, "Connecting to FastCast.");
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
if (connectedSocket != null) {
|
|
||||||
Logger.i(TAG, "Using connected socket.");
|
|
||||||
_socket = connectedSocket
|
|
||||||
connectedSocket = null
|
|
||||||
} else {
|
|
||||||
Logger.i(TAG, "Using new socket.");
|
|
||||||
_socket = Socket().apply { this.connect(address, 2000) };
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Successfully connected to FastCast at $usedRemoteAddress:$port");
|
|
||||||
|
|
||||||
_outputStream = _socket?.outputStream;
|
|
||||||
_inputStream = _socket?.inputStream;
|
|
||||||
} catch (e: IOException) {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Failed to connect to FastCast.", e);
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
localAddress = _socket?.localAddress
|
|
||||||
_lastPongTime = System.currentTimeMillis()
|
|
||||||
connectionState = CastConnectionState.CONNECTED
|
|
||||||
|
|
||||||
val buffer = ByteArray(4096);
|
|
||||||
|
|
||||||
Logger.i(TAG, "Started receiving.");
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
try {
|
|
||||||
val inputStream = _inputStream ?: break;
|
|
||||||
Log.d(TAG, "Receiving next packet...");
|
|
||||||
|
|
||||||
var headerBytesRead = 0
|
|
||||||
while (headerBytesRead < 4) {
|
|
||||||
val read = inputStream.read(buffer, headerBytesRead, 4 - headerBytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
headerBytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
|
||||||
var bytesRead = 0
|
|
||||||
while (bytesRead < size) {
|
|
||||||
val read = inputStream.read(buffer, bytesRead, size - bytesRead)
|
|
||||||
if (read == -1)
|
|
||||||
throw Exception("Stream closed")
|
|
||||||
bytesRead += read
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageBytes = buffer.sliceArray(IntRange(0, size));
|
|
||||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
|
||||||
|
|
||||||
val opcode = messageBytes[0];
|
|
||||||
var json: String? = null;
|
|
||||||
if (size > 1) {
|
|
||||||
json = messageBytes.sliceArray(IntRange(1, size - 1)).decodeToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
handleMessage(Opcode.find(opcode), json);
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to handle message.", e)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch (e: java.net.SocketException) {
|
|
||||||
Logger.e(TAG, "Socket exception while receiving.", e);
|
|
||||||
break
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Exception while receiving.", e);
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
Logger.i(TAG, "Socket disconnected.");
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState = CastConnectionState.CONNECTING;
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopped connection loop.");
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}.apply { start() }
|
|
||||||
|
|
||||||
_pingThread = Thread {
|
|
||||||
Logger.i(TAG, "Started ping loop.")
|
|
||||||
while (_scopeIO?.isActive == true) {
|
|
||||||
if (connectionState == CastConnectionState.CONNECTED) {
|
|
||||||
try {
|
|
||||||
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 send ping.")
|
|
||||||
try {
|
|
||||||
_socket?.close()
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(TAG, "Failed to close socket.", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Thread.sleep(5000)
|
|
||||||
}
|
|
||||||
Logger.i(TAG, "Stopped ping loop.")
|
|
||||||
}.apply { start() }
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Thread was still alive, not restarted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMessage(opcode: Opcode, json: String? = null) {
|
|
||||||
Log.i(TAG, "Processing packet (opcode: $opcode, size: ${json?.length ?: 0})")
|
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcode.PlaybackUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackUpdate = FCastCastingDevice.json.decodeFromString<FCastPlaybackUpdateMessage>(json);
|
|
||||||
setTime(playbackUpdate.time, playbackUpdate.generationTime);
|
|
||||||
setDuration(playbackUpdate.duration, playbackUpdate.generationTime);
|
|
||||||
isPlaying = when (playbackUpdate.state) {
|
|
||||||
1 -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Opcode.VolumeUpdate -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got volume update without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val volumeUpdate = FCastCastingDevice.json.decodeFromString<FCastVolumeUpdateMessage>(json);
|
|
||||||
setVolume(volumeUpdate.volume, volumeUpdate.generationTime);
|
|
||||||
}
|
|
||||||
Opcode.PlaybackError -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got playback error without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val playbackError = FCastCastingDevice.json.decodeFromString<FCastPlaybackErrorMessage>(json);
|
|
||||||
Logger.e(TAG, "Remote casting playback error received: $playbackError")
|
|
||||||
}
|
|
||||||
Opcode.Version -> {
|
|
||||||
if (json == null) {
|
|
||||||
Logger.w(TAG, "Got version without JSON, ignoring.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
val version = FCastCastingDevice.json.decodeFromString<FCastVersionMessage>(json);
|
|
||||||
_version = version.version;
|
|
||||||
Logger.i(TAG, "Remote version received: $version")
|
|
||||||
}
|
|
||||||
Opcode.Ping -> send(Opcode.Pong)
|
|
||||||
Opcode.Pong -> _lastPongTime = System.currentTimeMillis()
|
|
||||||
else -> { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun send(opcode: Opcode, message: String? = null) {
|
|
||||||
ensureNotMainThread()
|
|
||||||
|
|
||||||
synchronized (_outputStreamLock) {
|
|
||||||
try {
|
|
||||||
val data: ByteArray = message?.encodeToByteArray() ?: ByteArray(0)
|
|
||||||
val size = 1 + data.size
|
|
||||||
val outputStream = _outputStream
|
|
||||||
if (outputStream == null) {
|
|
||||||
Log.w(TAG, "Failed to send $size bytes, output stream is null.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val serializedSizeLE = ByteArray(4)
|
|
||||||
serializedSizeLE[0] = (size and 0xff).toByte()
|
|
||||||
serializedSizeLE[1] = (size shr 8 and 0xff).toByte()
|
|
||||||
serializedSizeLE[2] = (size shr 16 and 0xff).toByte()
|
|
||||||
serializedSizeLE[3] = (size shr 24 and 0xff).toByte()
|
|
||||||
outputStream.write(serializedSizeLE)
|
|
||||||
|
|
||||||
val opcodeBytes = ByteArray(1)
|
|
||||||
opcodeBytes[0] = opcode.value
|
|
||||||
outputStream.write(opcodeBytes)
|
|
||||||
|
|
||||||
if (data.isNotEmpty()) {
|
|
||||||
outputStream.write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "Sent $size bytes: (opcode: $opcode, body: $message).")
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to send message.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun <reified T> send(opcode: Opcode, message: T) {
|
|
||||||
try {
|
|
||||||
send(opcode, message?.let { Json.encodeToString(it) })
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.i(TAG, "Failed to encode message to string.", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Logger.i(TAG, "Stopping...");
|
|
||||||
usedRemoteAddress = null;
|
|
||||||
localAddress = null;
|
|
||||||
_started = false;
|
|
||||||
//TODO: Kill and/or join thread?
|
|
||||||
_thread = null;
|
|
||||||
_pingThread = null;
|
|
||||||
|
|
||||||
val socket = _socket;
|
|
||||||
val scopeIO = _scopeIO;
|
|
||||||
|
|
||||||
if (scopeIO != null && socket != null) {
|
|
||||||
Logger.i(TAG, "Cancelling scopeIO with open socket.")
|
|
||||||
|
|
||||||
scopeIO.launch {
|
|
||||||
socket.close();
|
|
||||||
_inputStream?.close()
|
|
||||||
_outputStream?.close()
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
scopeIO.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO with open socket.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scopeIO?.cancel();
|
|
||||||
Logger.i(TAG, "Cancelled scopeIO without open socket.")
|
|
||||||
}
|
|
||||||
|
|
||||||
_scopeIO = null;
|
|
||||||
_socket = null;
|
|
||||||
_outputStream = null;
|
|
||||||
_inputStream = null;
|
|
||||||
connectionState = CastConnectionState.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
|
||||||
return CastingDeviceInfo(name!!, CastProtocolType.FCAST, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port);
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val TAG = "FCastCastingDevice";
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
fun getKeyExchangeMessage(keyPair: KeyPair): FCastKeyExchangeMessage {
|
|
||||||
return FCastKeyExchangeMessage(1, Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateKeyPair(): KeyPair {
|
|
||||||
//modp14
|
|
||||||
val p = BigInteger("ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece45b3dc2007cb8a163bf0598da48361c55d39a69163fa8fd24cf5f83655d23dca3ad961c62f356208552bb9ed529077096966d670c354e4abc9804f1746c08ca18217c32905e462e36ce3be39e772c180e86039b2783a2ec07a28fb5c55df06f4c52c9de2bcbf6955817183995497cea956ae515d2261898fa051015728e5a8aacaa68ffffffffffffffff", 16)
|
|
||||||
val g = BigInteger("2", 16)
|
|
||||||
val dhSpec = DHParameterSpec(p, g)
|
|
||||||
|
|
||||||
val keyGen = KeyPairGenerator.getInstance("DH")
|
|
||||||
keyGen.initialize(dhSpec)
|
|
||||||
|
|
||||||
return keyGen.generateKeyPair()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun computeSharedSecret(privateKey: PrivateKey, keyExchangeMessage: FCastKeyExchangeMessage): SecretKeySpec {
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val receivedPublicKeyBytes = Base64.decode(keyExchangeMessage.publicKey, Base64.NO_WRAP)
|
|
||||||
val receivedPublicKeySpec = X509EncodedKeySpec(receivedPublicKeyBytes)
|
|
||||||
val receivedPublicKey = keyFactory.generatePublic(receivedPublicKeySpec)
|
|
||||||
|
|
||||||
val keyAgreement = KeyAgreement.getInstance("DH")
|
|
||||||
keyAgreement.init(privateKey)
|
|
||||||
keyAgreement.doPhase(receivedPublicKey, true)
|
|
||||||
|
|
||||||
val sharedSecret = keyAgreement.generateSecret()
|
|
||||||
Log.i(TAG, "sharedSecret ${Base64.encodeToString(sharedSecret, Base64.NO_WRAP)}")
|
|
||||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
|
||||||
val hashedSecret = sha256.digest(sharedSecret)
|
|
||||||
Log.i(TAG, "hashedSecret ${Base64.encodeToString(hashedSecret, Base64.NO_WRAP)}")
|
|
||||||
|
|
||||||
return SecretKeySpec(hashedSecret, "AES")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun encryptMessage(aesKey: SecretKeySpec, decryptedMessage: FCastDecryptedMessage): FCastEncryptedMessage {
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, aesKey)
|
|
||||||
val iv = cipher.iv
|
|
||||||
val json = Json.encodeToString(decryptedMessage)
|
|
||||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
|
||||||
return FCastEncryptedMessage(
|
|
||||||
version = 1,
|
|
||||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP),
|
|
||||||
blob = Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decryptMessage(aesKey: SecretKeySpec, encryptedMessage: FCastEncryptedMessage): FCastDecryptedMessage {
|
|
||||||
val iv = Base64.decode(encryptedMessage.iv, Base64.NO_WRAP)
|
|
||||||
val encrypted = Base64.decode(encryptedMessage.blob, Base64.NO_WRAP)
|
|
||||||
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, aesKey, IvParameterSpec(iv))
|
|
||||||
val decryptedJson = cipher.doFinal(encrypted)
|
|
||||||
return Json.decodeFromString(String(decryptedJson, Charsets.UTF_8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,9 @@ import android.content.Context
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.futo.platformplayer.BuildConfig
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
@@ -14,9 +16,11 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
|
|||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
import com.futo.platformplayer.api.http.server.ManagedHttpServer
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpConstantHandler
|
||||||
|
import com.futo.platformplayer.api.http.server.handlers.HttpContentUriHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFileHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpProxyHandler
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
|
||||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
|
||||||
@@ -33,7 +37,11 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
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.JSDashManifestRawSource
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||||
|
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||||
|
import com.futo.platformplayer.awaitCancelConverted
|
||||||
import com.futo.platformplayer.builders.DashBuilder
|
import com.futo.platformplayer.builders.DashBuilder
|
||||||
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.constructs.Event2
|
import com.futo.platformplayer.constructs.Event2
|
||||||
@@ -50,16 +58,22 @@ import com.futo.platformplayer.views.casting.CastView.Companion
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.fcast.sender_sdk.CastContext
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo
|
||||||
import org.fcast.sender_sdk.Metadata
|
import org.fcast.sender_sdk.Metadata
|
||||||
|
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||||
|
import org.fcast.sender_sdk.ProtocolType
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||||
|
|
||||||
abstract class StateCasting {
|
class StateCasting {
|
||||||
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
val _scopeIO = CoroutineScope(Dispatchers.IO);
|
||||||
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
val _scopeMain = CoroutineScope(Dispatchers.Main);
|
||||||
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
|
||||||
@@ -76,6 +90,7 @@ abstract class StateCasting {
|
|||||||
val onActiveDeviceTimeChanged = Event1<Double>();
|
val onActiveDeviceTimeChanged = Event1<Double>();
|
||||||
val onActiveDeviceDurationChanged = Event1<Double>();
|
val onActiveDeviceDurationChanged = Event1<Double>();
|
||||||
val onActiveDeviceVolumeChanged = Event1<Double>();
|
val onActiveDeviceVolumeChanged = Event1<Double>();
|
||||||
|
val onActiveDeviceMediaItemEnd = Event0()
|
||||||
var activeDevice: CastingDevice? = null;
|
var activeDevice: CastingDevice? = null;
|
||||||
private var _videoExecutor: JSRequestExecutor? = null
|
private var _videoExecutor: JSRequestExecutor? = null
|
||||||
private var _audioExecutor: JSRequestExecutor? = null
|
private var _audioExecutor: JSRequestExecutor? = null
|
||||||
@@ -84,16 +99,163 @@ abstract class StateCasting {
|
|||||||
val isCasting: Boolean get() = activeDevice != null;
|
val isCasting: Boolean get() = activeDevice != null;
|
||||||
private val _castId = AtomicInteger(0)
|
private val _castId = AtomicInteger(0)
|
||||||
|
|
||||||
abstract fun handleUrl(url: String)
|
private val _context = CastContext()
|
||||||
abstract fun onStop()
|
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||||
abstract fun start(context: Context)
|
|
||||||
abstract fun stop()
|
|
||||||
|
|
||||||
@Throws
|
class DiscoveryEventHandler(
|
||||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice
|
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||||
abstract fun startUpdateTimeJob(
|
private val onDeviceRemoved: (String) -> Unit,
|
||||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||||
): Job?
|
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||||
|
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceAdded(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||||
|
onDeviceUpdated(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deviceRemoved(deviceName: String) {
|
||||||
|
onDeviceRemoved(deviceName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||||
|
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||||
|
connectDevice(CastingDevice(foundDevice))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle URL: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStop() {
|
||||||
|
val ad = activeDevice ?: return
|
||||||
|
_resumeCastingDevice = ad.getDeviceInfo()
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||||
|
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||||
|
try {
|
||||||
|
ad.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (_started)
|
||||||
|
return
|
||||||
|
_started = true
|
||||||
|
|
||||||
|
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||||
|
_resumeCastingDevice = null
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService starting...")
|
||||||
|
|
||||||
|
_castServer.start()
|
||||||
|
enableDeveloper(true)
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService started.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||||
|
context,
|
||||||
|
DiscoveryEventHandler(
|
||||||
|
{ deviceInfo -> // Added
|
||||||
|
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||||
|
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||||
|
val deviceHandle = CastingDevice(device)
|
||||||
|
devices[deviceHandle.device.name()] = deviceHandle
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceAdded.emit(deviceHandle)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceName -> // Removed
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
if (devices.containsKey(deviceName)) {
|
||||||
|
val device = devices.remove(deviceName)
|
||||||
|
if (device != null) {
|
||||||
|
onDeviceRemoved.emit(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceInfo -> // Updated
|
||||||
|
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||||
|
val handle = devices[deviceInfo.name]
|
||||||
|
if (handle != null && handle is CastingDevice) {
|
||||||
|
handle.device.setPort(deviceInfo.port)
|
||||||
|
handle.device.setAddresses(deviceInfo.addresses)
|
||||||
|
invokeInMainScopeIfRequired {
|
||||||
|
onDeviceChanged.emit(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop() {
|
||||||
|
if (!_started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_started = false
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopping.")
|
||||||
|
|
||||||
|
_scopeIO.cancel()
|
||||||
|
_scopeMain.cancel()
|
||||||
|
|
||||||
|
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||||
|
val d = activeDevice
|
||||||
|
activeDevice = null
|
||||||
|
try {
|
||||||
|
d?.disconnect()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.stop()
|
||||||
|
_castServer.removeAllHandlers()
|
||||||
|
|
||||||
|
Logger.i(TAG, "CastingService stopped.")
|
||||||
|
|
||||||
|
_deviceDiscoverer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startUpdateTimeJob(
|
||||||
|
onTimeJobTimeChanged_s: Event1<Long>,
|
||||||
|
setTime: (Long) -> Unit
|
||||||
|
): Job? = null
|
||||||
|
|
||||||
|
fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice? {
|
||||||
|
try {
|
||||||
|
val rsAddrs =
|
||||||
|
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||||
|
val rsDeviceInfo = RsDeviceInfo(
|
||||||
|
name = deviceInfo.name,
|
||||||
|
protocol = when (deviceInfo.type) {
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||||
|
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||||
|
else -> throw IllegalArgumentException()
|
||||||
|
},
|
||||||
|
addresses = rsAddrs,
|
||||||
|
port = deviceInfo.port.toUShort(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CastingDevice(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onResume() {
|
fun onResume() {
|
||||||
val ad = activeDevice
|
val ad = activeDevice
|
||||||
@@ -140,6 +302,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
ad.disconnect()
|
ad.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +317,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
activeDevice = null;
|
activeDevice = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +381,9 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.subscribe {
|
device.onTimeChanged.subscribe {
|
||||||
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
invokeInMainScopeIfRequired { onActiveDeviceTimeChanged.emit(it) };
|
||||||
};
|
};
|
||||||
|
device.onMediaItemEnd.subscribe {
|
||||||
|
invokeInMainScopeIfRequired { onActiveDeviceMediaItemEnd.emit() }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
device.connect();
|
device.connect();
|
||||||
@@ -227,6 +394,7 @@ abstract class StateCasting {
|
|||||||
device.onTimeChanged.clear();
|
device.onTimeChanged.clear();
|
||||||
device.onVolumeChanged.clear();
|
device.onVolumeChanged.clear();
|
||||||
device.onDurationChanged.clear();
|
device.onDurationChanged.clear();
|
||||||
|
device.onMediaItemEnd.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +402,9 @@ abstract class StateCasting {
|
|||||||
Logger.i(TAG, "Connect to device ${device.name}")
|
Logger.i(TAG, "Connect to device ${device.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun metadataFromVideo(video: IPlatformVideoDetails): Metadata {
|
fun metadataFromVideo(video: IPlatformVideoDetails, videoThumbnailOverrideUrl: String? = null): Metadata {
|
||||||
return Metadata(
|
return Metadata(
|
||||||
title = video.name, thumbnailUrl = video.thumbnails.getHQThumbnail()
|
title = video.name, thumbnailUrl = videoThumbnailOverrideUrl ?: video.thumbnails.getHQThumbnail()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,20 +463,63 @@ abstract class StateCasting {
|
|||||||
val url = getLocalUrl(ad);
|
val url = getLocalUrl(ad);
|
||||||
val id = UUID.randomUUID();
|
val id = UUID.randomUUID();
|
||||||
|
|
||||||
|
|
||||||
if (videoSource is IVideoUrlSource) {
|
if (videoSource is IVideoUrlSource) {
|
||||||
val videoPath = "/video-${id}"
|
val videoPath = "/video-$id"
|
||||||
val videoUrl = if(proxyStreams) url + videoPath else videoSource.getVideoUrl();
|
val upstreamUrl = videoSource.getVideoUrl()
|
||||||
Logger.i(TAG, "Casting as singular video");
|
val videoUrl = if (proxyStreams) url + videoPath else upstreamUrl
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
val jsReqMod = (videoSource as? JSSource)?.getRequestModifier()
|
||||||
|
|
||||||
|
if (proxyStreams) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpProxyHandler("GET", videoPath, upstreamUrl, true)
|
||||||
|
.withIRequestModifier(jsReqMod)
|
||||||
|
.withInjectedHost()
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||||
|
true
|
||||||
|
).withTag("castSingular")
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting as singular video (proxy=$proxyStreams, url=$videoUrl)")
|
||||||
|
ad.loadVideo(
|
||||||
|
if (video.isLive) "LIVE" else "BUFFERED",
|
||||||
|
videoSource.container,
|
||||||
|
videoUrl,
|
||||||
|
resumePosition,
|
||||||
|
video.duration.toDouble(),
|
||||||
|
speed,
|
||||||
|
metadataFromVideo(video)
|
||||||
|
)
|
||||||
} else if (audioSource is IAudioUrlSource) {
|
} else if (audioSource is IAudioUrlSource) {
|
||||||
val audioPath = "/audio-${id}"
|
val audioPath = "/audio-$id"
|
||||||
val audioUrl = if(proxyStreams) url + audioPath else audioSource.getAudioUrl();
|
val upstreamUrl = audioSource.getAudioUrl()
|
||||||
Logger.i(TAG, "Casting as singular audio");
|
val audioUrl = if (proxyStreams) url + audioPath else upstreamUrl
|
||||||
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
val jsReqMod = (audioSource as? JSSource)?.getRequestModifier()
|
||||||
|
|
||||||
|
if (proxyStreams) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpProxyHandler("GET", audioPath, upstreamUrl, true)
|
||||||
|
.withIRequestModifier(jsReqMod)
|
||||||
|
.withInjectedHost()
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||||
|
true
|
||||||
|
).withTag("castSingular")
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting as singular audio (proxy=$proxyStreams, url=$audioUrl)")
|
||||||
|
ad.loadVideo(
|
||||||
|
if (video.isLive) "LIVE" else "BUFFERED",
|
||||||
|
audioSource.container,
|
||||||
|
audioUrl,
|
||||||
|
resumePosition,
|
||||||
|
video.duration.toDouble(),
|
||||||
|
speed,
|
||||||
|
metadataFromVideo(video)
|
||||||
|
)
|
||||||
} else if (videoSource is IHLSManifestSource) {
|
} else if (videoSource is IHLSManifestSource) {
|
||||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||||
Logger.i(TAG, "Casting as proxied HLS");
|
Logger.i(TAG, "Casting as proxied HLS");
|
||||||
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed);
|
castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed, (videoSource as JSSource?)?.getRequestModifier());
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Casting as non-proxied HLS");
|
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, metadataFromVideo(video));
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||||
@@ -316,7 +527,7 @@ abstract class StateCasting {
|
|||||||
} else if (audioSource is IHLSManifestAudioSource) {
|
} else if (audioSource is IHLSManifestAudioSource) {
|
||||||
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
if (proxyStreams || deviceProto == CastProtocolType.CHROMECAST) {
|
||||||
Logger.i(TAG, "Casting as proxied audio HLS");
|
Logger.i(TAG, "Casting as proxied audio HLS");
|
||||||
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed);
|
castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed, (audioSource as JSSource?)?.getRequestModifier());
|
||||||
} else {
|
} else {
|
||||||
Logger.i(TAG, "Casting as non-proxied audio HLS");
|
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, metadataFromVideo(video));
|
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video));
|
||||||
@@ -327,6 +538,12 @@ abstract class StateCasting {
|
|||||||
} else if (audioSource is LocalAudioSource) {
|
} else if (audioSource is LocalAudioSource) {
|
||||||
Logger.i(TAG, "Casting as local audio");
|
Logger.i(TAG, "Casting as local audio");
|
||||||
castLocalAudio(video, audioSource, resumePosition, speed);
|
castLocalAudio(video, audioSource, resumePosition, speed);
|
||||||
|
} else if (videoSource is LocalVideoContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local video");
|
||||||
|
castLocalVideo(contentResolver, video, videoSource, resumePosition, speed);
|
||||||
|
} else if (audioSource is LocalAudioContentSource) {
|
||||||
|
Logger.i(TAG, "Casting as local audio");
|
||||||
|
castLocalAudio(contentResolver, video, audioSource, resumePosition, speed);
|
||||||
} else if (videoSource is JSDashManifestRawSource) {
|
} else if (videoSource is JSDashManifestRawSource) {
|
||||||
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
Logger.i(TAG, "Casting as JSDashManifestRawSource video");
|
||||||
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
castDashRaw(contentResolver, video, videoSource as JSDashManifestRawSource?, null, null, resumePosition, speed, castId, onLoadingEstimate, onLoading);
|
||||||
@@ -347,6 +564,11 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun HttpProxyHandler.withIRequestModifier(requestModifier: IRequestModifier?): HttpProxyHandler {
|
||||||
|
if (requestModifier == null) return this
|
||||||
|
return withRequestModifier { url, headers -> requestModifier.modifyRequest(url, headers) }
|
||||||
|
}
|
||||||
|
|
||||||
fun resumeVideo(): Boolean {
|
fun resumeVideo(): Boolean {
|
||||||
val ad = activeDevice ?: return false;
|
val ad = activeDevice ?: return false;
|
||||||
try {
|
try {
|
||||||
@@ -412,6 +634,65 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun castLocalVideo(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: LocalVideoContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val videoPath = "/video-${id}"
|
||||||
|
val videoUrl = url + videoPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", videoPath, contentResolver, videoSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local video (videoUrl: $videoUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(videoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun castLocalAudio(contentResolver: ContentResolver, video: IPlatformVideoDetails, audioSource: LocalAudioContentSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
|
val url = getLocalUrl(ad);
|
||||||
|
val id = UUID.randomUUID();
|
||||||
|
val audioPath = "/audio-${id}"
|
||||||
|
val audioUrl = url + audioPath;
|
||||||
|
val thumbnailPath = "/thumbnail-${id}"
|
||||||
|
val thumbnailUrl = url + thumbnailPath;
|
||||||
|
val thumbnailContentUrl = video.thumbnails.getHQThumbnail()
|
||||||
|
|
||||||
|
if (thumbnailContentUrl != null) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", thumbnailPath, contentResolver, thumbnailContentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpContentUriHandler("GET", audioPath, contentResolver, audioSource.contentUrl.toUri())
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
|
).withTag("cast");
|
||||||
|
|
||||||
|
Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl).");
|
||||||
|
ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed, metadataFromVideo(video, if (thumbnailContentUrl != null) thumbnailUrl else null));
|
||||||
|
|
||||||
|
return listOf(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||||
val ad = activeDevice ?: return listOf();
|
val ad = activeDevice ?: return listOf();
|
||||||
|
|
||||||
@@ -639,6 +920,7 @@ abstract class StateCasting {
|
|||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -646,6 +928,7 @@ abstract class StateCasting {
|
|||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -665,7 +948,8 @@ abstract class StateCasting {
|
|||||||
sourceUrl: String,
|
sourceUrl: String,
|
||||||
codec: String?,
|
codec: String?,
|
||||||
resumePosition: Double,
|
resumePosition: Double,
|
||||||
speed: Double?
|
speed: Double?,
|
||||||
|
requestModifier: IRequestModifier?
|
||||||
): List<String> {
|
): List<String> {
|
||||||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||||
|
|
||||||
@@ -686,7 +970,8 @@ abstract class StateCasting {
|
|||||||
val headers = masterContext.headers.clone()
|
val headers = masterContext.headers.clone()
|
||||||
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
headers["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val masterPlaylistResponse = _client.get(sourceUrl)
|
val masterPlaylistResponse = _client.get(sourceUrl, mutableMapOf(), requestModifier)
|
||||||
|
|
||||||
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
|
||||||
|
|
||||||
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
val masterPlaylistContent = masterPlaylistResponse.body?.string()
|
||||||
@@ -706,7 +991,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
HLS.parseVariantPlaylist(masterPlaylistContent, sourceUrl)
|
||||||
val proxiedVariantPlaylist =
|
val proxiedVariantPlaylist =
|
||||||
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive)
|
proxyVariantPlaylist(url, id, variantPlaylist, video.isLive, requestModifier)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
masterContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
return@HttpFunctionHandler
|
return@HttpFunctionHandler
|
||||||
@@ -738,7 +1023,7 @@ abstract class StateCasting {
|
|||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val response = _client.get(variantPlaylistRef.url)
|
val response = _client.get(variantPlaylistRef.url, mutableMapOf(), requestModifier)
|
||||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
val vpContent = response.body?.string()
|
val vpContent = response.body?.string()
|
||||||
@@ -747,7 +1032,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
HLS.parseVariantPlaylist(vpContent, variantPlaylistRef.url)
|
||||||
val proxiedVariantPlaylist =
|
val proxiedVariantPlaylist =
|
||||||
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
|
proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive, requestModifier)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
@@ -775,7 +1060,7 @@ abstract class StateCasting {
|
|||||||
val vpHeaders = vpContext.headers.clone()
|
val vpHeaders = vpContext.headers.clone()
|
||||||
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
|
||||||
|
|
||||||
val response = _client.get(mediaRendition.uri)
|
val response = _client.get(mediaRendition.uri, mutableMapOf(), requestModifier)
|
||||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||||
|
|
||||||
val vpContent = response.body?.string()
|
val vpContent = response.body?.string()
|
||||||
@@ -784,7 +1069,7 @@ abstract class StateCasting {
|
|||||||
val variantPlaylist =
|
val variantPlaylist =
|
||||||
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
HLS.parseVariantPlaylist(vpContent, mediaRendition.uri)
|
||||||
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
val proxiedVariantPlaylist = proxyVariantPlaylist(
|
||||||
url, playlistId, variantPlaylist, video.isLive
|
url, playlistId, variantPlaylist, video.isLive, requestModifier
|
||||||
)
|
)
|
||||||
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
|
||||||
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
|
||||||
@@ -826,13 +1111,13 @@ abstract class StateCasting {
|
|||||||
return listOf(hlsUrl);
|
return listOf(hlsUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, requestModifier: IRequestModifier?, proxySegments: Boolean = true): HLS.VariantPlaylist {
|
||||||
val newSegments = arrayListOf<HLS.Segment>()
|
val newSegments = arrayListOf<HLS.Segment>()
|
||||||
|
|
||||||
if (proxySegments) {
|
if (proxySegments) {
|
||||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||||
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
val sequenceNumber = (variantPlaylist.mediaSequence ?: 0) + index.toLong()
|
||||||
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
|
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber, requestModifier))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newSegments.addAll(variantPlaylist.segments)
|
newSegments.addAll(variantPlaylist.segments)
|
||||||
@@ -850,7 +1135,7 @@ abstract class StateCasting {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
|
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long, requestModifier: IRequestModifier?): HLS.Segment {
|
||||||
if (segment is HLS.MediaSegment) {
|
if (segment is HLS.MediaSegment) {
|
||||||
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
|
||||||
val newSegmentUrl = url + newSegmentPath;
|
val newSegmentUrl = url + newSegmentPath;
|
||||||
@@ -858,6 +1143,7 @@ abstract class StateCasting {
|
|||||||
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
if (_castServer.getHandler("GET", newSegmentPath) == null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
|
||||||
|
.withIRequestModifier(requestModifier)
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castProxiedHlsVariant")
|
).withTag("castProxiedHlsVariant")
|
||||||
@@ -905,6 +1191,7 @@ abstract class StateCasting {
|
|||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castHlsIndirectVariant");
|
).withTag("castHlsIndirectVariant");
|
||||||
@@ -982,6 +1269,7 @@ abstract class StateCasting {
|
|||||||
|
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castHlsIndirectVariant");
|
).withTag("castHlsIndirectVariant");
|
||||||
@@ -1065,6 +1353,7 @@ abstract class StateCasting {
|
|||||||
if (videoSource != null) {
|
if (videoSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
|
||||||
|
.withIRequestModifier((videoSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -1072,6 +1361,7 @@ abstract class StateCasting {
|
|||||||
if (audioSource != null) {
|
if (audioSource != null) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
|
||||||
|
.withIRequestModifier((audioSource as? JSSource)?.getRequestModifier())
|
||||||
.withInjectedHost()
|
.withInjectedHost()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("cast");
|
).withTag("cast");
|
||||||
@@ -1111,6 +1401,47 @@ abstract class StateCasting {
|
|||||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun escapeXml(s: String): String =
|
||||||
|
s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
|
||||||
|
private fun injectSubtitleAdaptationSet(
|
||||||
|
mpd: String,
|
||||||
|
subtitleUrl: String,
|
||||||
|
mimeType: String,
|
||||||
|
lang: String = "und",
|
||||||
|
label: String = "Subtitles"
|
||||||
|
): String {
|
||||||
|
val mt = mimeType.lowercase()
|
||||||
|
val codecs = when (mt) {
|
||||||
|
"text/vtt", "text/webvtt" -> "wvtt"
|
||||||
|
"application/ttml+xml", "application/ttml" -> "stpp"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val codecsAttr = codecs?.let { " codecs=\"${escapeXml(it)}\"" } ?: ""
|
||||||
|
|
||||||
|
val adaptation = """
|
||||||
|
<AdaptationSet id="123456" contentType="text" mimeType="${escapeXml(mimeType)}" lang="${escapeXml(lang)}">
|
||||||
|
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="subtitle"/>
|
||||||
|
<Label>${escapeXml(label)}</Label>
|
||||||
|
<Representation id="123457"$codecsAttr bandwidth="256" mimeType="${escapeXml(mimeType)}">
|
||||||
|
<BaseURL>${escapeXml(subtitleUrl)}</BaseURL>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val periodClose = Regex("</Period\\s*>", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
return if (periodClose.containsMatchIn(mpd)) {
|
||||||
|
mpd.replaceFirst(periodClose, adaptation + "\n</Period>")
|
||||||
|
} else {
|
||||||
|
mpd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
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> {
|
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();
|
val ad = activeDevice ?: return listOf();
|
||||||
@@ -1132,30 +1463,42 @@ abstract class StateCasting {
|
|||||||
val videoUrl = url + videoPath
|
val videoUrl = url + videoPath
|
||||||
val audioUrl = url + audioPath
|
val audioUrl = url + audioPath
|
||||||
|
|
||||||
|
val subtitleMimeTypeFull = subtitleSource?.format ?: "text/vtt"
|
||||||
|
val subtitleMimeTypeForMpd = subtitleMimeTypeFull.substringBefore(';').trim()
|
||||||
|
|
||||||
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
|
||||||
return@withContext subtitleSource.getSubtitlesURI();
|
subtitleSource.getSubtitlesURI()
|
||||||
} else null;
|
} else null
|
||||||
|
|
||||||
var subtitlesUrl: String? = null;
|
var subtitlesUrl: String? = null
|
||||||
if (subtitlesUri != null) {
|
if (subtitlesUri != null) {
|
||||||
if(subtitlesUri.scheme == "file") {
|
when (subtitlesUri.scheme) {
|
||||||
var content: String? = null;
|
"file", "content" -> {
|
||||||
val inputStream = contentResolver.openInputStream(subtitlesUri);
|
val content = withContext(Dispatchers.IO) {
|
||||||
inputStream?.use { stream ->
|
contentResolver.openInputStream(subtitlesUri)?.use { stream ->
|
||||||
val reader = stream.bufferedReader();
|
stream.bufferedReader().use { it.readText() }
|
||||||
content = reader.use { it.readText() };
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.isNullOrEmpty()) {
|
||||||
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
|
HttpConstantHandler("GET", subtitlePath, content, subtitleMimeTypeFull)
|
||||||
|
.withHeader("Access-Control-Allow-Origin", "*"),
|
||||||
|
true
|
||||||
|
).withTag("castDashRaw")
|
||||||
|
|
||||||
|
subtitlesUrl = url + subtitlePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content != null) {
|
"http", "https" -> {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
// Receiver will fetch directly (works only if it doesn’t need auth/headers)
|
||||||
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
|
subtitlesUrl = subtitlesUri.toString()
|
||||||
.withHeader("Access-Control-Allow-Origin", "*"), true
|
|
||||||
).withTag("cast");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitlesUrl = url + subtitlePath;
|
else -> {
|
||||||
} else {
|
Logger.w(TAG, "Unsupported subtitlesUri scheme: ${subtitlesUri.scheme}")
|
||||||
subtitlesUrl = subtitlesUri.toString();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1183,7 +1526,7 @@ abstract class StateCasting {
|
|||||||
onLoading?.invoke(true)
|
onLoading?.invoke(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deferred.await()
|
deferred.awaitCancelConverted()
|
||||||
} finally {
|
} finally {
|
||||||
if (castId == _castId.get()) {
|
if (castId == _castId.get()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -1201,8 +1544,22 @@ abstract class StateCasting {
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitlesUrl != null) {
|
||||||
|
dashContent = injectSubtitleAdaptationSet(
|
||||||
|
dashContent,
|
||||||
|
subtitlesUrl!!,
|
||||||
|
subtitleMimeTypeForMpd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasAudioInDash = false
|
||||||
for (representation in representationRegex.findAll(dashContent)) {
|
for (representation in representationRegex.findAll(dashContent)) {
|
||||||
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
val mediaType = representation.groups[1]?.value ?: throw Exception("Media type should be found")
|
||||||
|
|
||||||
|
if (mediaType.startsWith("audio/")) {
|
||||||
|
hasAudioInDash = true
|
||||||
|
}
|
||||||
|
|
||||||
dashContent = mediaInitializationRegex.replace(dashContent) {
|
dashContent = mediaInitializationRegex.replace(dashContent) {
|
||||||
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
if (it.range.first < representation.range.first || it.range.last > representation.range.last) {
|
||||||
return@replace it.value
|
return@replace it.value
|
||||||
@@ -1226,12 +1583,20 @@ abstract class StateCasting {
|
|||||||
throw Exception("Audio source without request executor not supported")
|
throw Exception("Audio source without request executor not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audioSource != null && audioSource.hasRequestExecutor) {
|
if (videoSource != null && videoSource.hasRequestExecutor) {
|
||||||
_audioExecutor = audioSource.getRequestExecutor()
|
val oldVideoExecutor = _videoExecutor
|
||||||
|
oldVideoExecutor?.closeAsync()
|
||||||
|
_videoExecutor = videoSource.getRequestExecutor()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoSource != null && videoSource.hasRequestExecutor) {
|
if (audioSource != null) {
|
||||||
_videoExecutor = videoSource.getRequestExecutor()
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = audioSource.getRequestExecutor()
|
||||||
|
} else if (hasAudioInDash && videoSource != null) {
|
||||||
|
val oldExecutor = _audioExecutor
|
||||||
|
oldExecutor?.closeAsync()
|
||||||
|
_audioExecutor = _videoExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
//TOOD: Else also handle the non request executor case, perhaps add ?url=$originalUrl to the query parameters, ... propagate this for all other flows also
|
||||||
@@ -1262,7 +1627,7 @@ abstract class StateCasting {
|
|||||||
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
}.withHeader("Access-Control-Allow-Origin", "*"), true
|
||||||
).withTag("castDashRaw");
|
).withTag("castDashRaw");
|
||||||
}
|
}
|
||||||
if (audioSource != null) {
|
if (audioSource != null || (audioSource == null && hasAudioInDash)) {
|
||||||
_castServer.addHandlerWithAllowAllOptions(
|
_castServer.addHandlerWithAllowAllOptions(
|
||||||
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
HttpFunctionHandler("GET", audioPath) { httpContext ->
|
||||||
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
val originalUrl = httpContext.query["url"]?.let { URLDecoder.decode(it, "UTF-8") } ?: return@HttpFunctionHandler
|
||||||
@@ -1287,9 +1652,11 @@ abstract class StateCasting {
|
|||||||
return listOf()
|
return listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo? {
|
||||||
val device = deviceFromInfo(deviceInfo);
|
return when (val device = deviceFromInfo(deviceInfo)) {
|
||||||
return addRememberedDevice(device);
|
null -> null
|
||||||
|
else -> addRememberedDevice(device)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||||
@@ -1298,7 +1665,7 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||||
return _storage.getDevices().map { deviceFromInfo(it) }
|
return _storage.getDevices().map { deviceFromInfo(it) }.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRememberedCastingDeviceNames(): List<String> {
|
fun getRememberedCastingDeviceNames(): List<String> {
|
||||||
@@ -1325,11 +1692,7 @@ abstract class StateCasting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var instance: StateCasting = if (Settings.instance.casting.experimentalCasting) {
|
var instance = StateCasting()
|
||||||
StateCastingExp()
|
|
||||||
} else {
|
|
||||||
StateCastingLegacy()
|
|
||||||
}
|
|
||||||
private val representationRegex = Regex(
|
private val representationRegex = Regex(
|
||||||
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
"<Representation .*?mimeType=\"(.*?)\".*?>(.*?)<\\/Representation>",
|
||||||
RegexOption.DOT_MATCHES_ALL
|
RegexOption.DOT_MATCHES_ALL
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.BuildConfig
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
|
||||||
import org.fcast.sender_sdk.ProtocolType
|
|
||||||
import org.fcast.sender_sdk.CastContext
|
|
||||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
|
||||||
|
|
||||||
class StateCastingExp : StateCasting() {
|
|
||||||
private val _context = CastContext()
|
|
||||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
|
||||||
|
|
||||||
class DiscoveryEventHandler(
|
|
||||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
|
||||||
private val onDeviceRemoved: (String) -> Unit,
|
|
||||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
|
||||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
|
||||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceAdded(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
|
||||||
onDeviceUpdated(deviceInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceRemoved(deviceName: String) {
|
|
||||||
onDeviceRemoved(deviceName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
try {
|
|
||||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
|
||||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
|
||||||
connectDevice(CastingDeviceExp(foundDevice))
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to handle URL: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
|
||||||
try {
|
|
||||||
ad.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return
|
|
||||||
_started = true
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...")
|
|
||||||
|
|
||||||
_castServer.start()
|
|
||||||
enableDeveloper(true)
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
|
||||||
context,
|
|
||||||
DiscoveryEventHandler(
|
|
||||||
{ deviceInfo -> // Added
|
|
||||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
|
||||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
|
||||||
val deviceHandle = CastingDeviceExp(device)
|
|
||||||
devices[deviceHandle.device.name()] = deviceHandle
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceAdded.emit(deviceHandle)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceName -> // Removed
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
if (devices.containsKey(deviceName)) {
|
|
||||||
val device = devices.remove(deviceName)
|
|
||||||
if (device != null) {
|
|
||||||
onDeviceRemoved.emit(device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deviceInfo -> // Updated
|
|
||||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
|
||||||
val handle = devices[deviceInfo.name]
|
|
||||||
if (handle != null && handle is CastingDeviceExp) {
|
|
||||||
handle.device.setPort(deviceInfo.port)
|
|
||||||
handle.device.setAddresses(deviceInfo.addresses)
|
|
||||||
invokeInMainScopeIfRequired {
|
|
||||||
onDeviceChanged.emit(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = false
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
_scopeIO.cancel()
|
|
||||||
_scopeMain.cancel()
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice
|
|
||||||
activeDevice = null
|
|
||||||
try {
|
|
||||||
d?.disconnect()
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
|
||||||
}
|
|
||||||
|
|
||||||
_castServer.stop()
|
|
||||||
_castServer.removeAllHandlers()
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopped.")
|
|
||||||
|
|
||||||
_deviceDiscoverer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? = null
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
|
|
||||||
val rsAddrs =
|
|
||||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
|
|
||||||
val rsDeviceInfo = RsDeviceInfo(
|
|
||||||
name = deviceInfo.name,
|
|
||||||
protocol = when (deviceInfo.type) {
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
|
||||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
|
||||||
else -> throw IllegalArgumentException()
|
|
||||||
},
|
|
||||||
addresses = rsAddrs,
|
|
||||||
port = deviceInfo.port.toUShort(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingExp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.net.nsd.NsdManager
|
|
||||||
import android.net.nsd.NsdServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import com.futo.platformplayer.constructs.Event1
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import java.net.InetAddress
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
class StateCastingLegacy : StateCasting() {
|
|
||||||
private var _nsdManager: NsdManager? = null
|
|
||||||
|
|
||||||
private val _discoveryListeners = mapOf(
|
|
||||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
|
||||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
|
||||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
|
||||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun handleUrl(url: String) {
|
|
||||||
val uri = Uri.parse(url)
|
|
||||||
if (uri.scheme != "fcast") {
|
|
||||||
throw Exception("Expected scheme to be FCast")
|
|
||||||
}
|
|
||||||
|
|
||||||
val type = uri.host
|
|
||||||
if (type != "r") {
|
|
||||||
throw Exception("Expected type r")
|
|
||||||
}
|
|
||||||
|
|
||||||
val connectionInfo = uri.pathSegments[0]
|
|
||||||
val json =
|
|
||||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
|
||||||
.toString(Charsets.UTF_8)
|
|
||||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
|
||||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
|
||||||
|
|
||||||
val foundInfo = addRememberedDevice(
|
|
||||||
CastingDeviceInfo(
|
|
||||||
name = networkConfig.name,
|
|
||||||
type = CastProtocolType.FCAST,
|
|
||||||
addresses = networkConfig.addresses.toTypedArray(),
|
|
||||||
port = tcpService.port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
connectDevice(deviceFromInfo(foundInfo))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
val ad = activeDevice ?: return;
|
|
||||||
_resumeCastingDevice = ad.getDeviceInfo()
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
|
||||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
|
||||||
ad.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun start(context: Context) {
|
|
||||||
if (_started)
|
|
||||||
return;
|
|
||||||
_started = true;
|
|
||||||
|
|
||||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
|
||||||
_resumeCastingDevice = null;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService starting...");
|
|
||||||
|
|
||||||
_castServer.start();
|
|
||||||
enableDeveloper(true);
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService started.");
|
|
||||||
|
|
||||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
|
||||||
startDiscovering()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun startDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun stopDiscovering() {
|
|
||||||
_nsdManager?.apply {
|
|
||||||
_discoveryListeners.forEach {
|
|
||||||
try {
|
|
||||||
stopServiceDiscovery(it.value)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
override fun stop() {
|
|
||||||
if (!_started)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_started = false;
|
|
||||||
|
|
||||||
Logger.i(TAG, "CastingService stopping.")
|
|
||||||
|
|
||||||
stopDiscovering()
|
|
||||||
_scopeIO.cancel();
|
|
||||||
_scopeMain.cancel();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
|
||||||
val d = activeDevice;
|
|
||||||
activeDevice = null;
|
|
||||||
d?.disconnect();
|
|
||||||
|
|
||||||
_castServer.stop();
|
|
||||||
_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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun startUpdateTimeJob(
|
|
||||||
onTimeJobTimeChanged_s: Event1<Long>,
|
|
||||||
setTime: (Long) -> Unit
|
|
||||||
): Job? {
|
|
||||||
val d = activeDevice;
|
|
||||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
|
||||||
return _scopeMain.launch {
|
|
||||||
while (true) {
|
|
||||||
val device = instance.activeDevice
|
|
||||||
if (device == null || !device.isPlaying) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
|
||||||
setTime(time_ms)
|
|
||||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
|
||||||
return CastingDeviceLegacyWrapper(
|
|
||||||
when (deviceInfo.type) {
|
|
||||||
CastProtocolType.CHROMECAST -> {
|
|
||||||
ChromecastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.AIRPLAY -> {
|
|
||||||
AirPlayCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
CastProtocolType.FCAST -> {
|
|
||||||
FCastCastingDevice(deviceInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateChromeCastDevice(
|
|
||||||
name: String,
|
|
||||||
addresses: Array<InetAddress>,
|
|
||||||
port: Int
|
|
||||||
) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
ChromecastCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
d.inner.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = {
|
|
||||||
CastingDeviceLegacyWrapper(
|
|
||||||
AirPlayCastingDevice(
|
|
||||||
name,
|
|
||||||
addresses,
|
|
||||||
port
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
|
||||||
return addOrUpdateCastDevice(
|
|
||||||
name,
|
|
||||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
|
||||||
deviceUpdater = { d ->
|
|
||||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
|
||||||
return@addOrUpdateCastDevice false;
|
|
||||||
}
|
|
||||||
|
|
||||||
val changed =
|
|
||||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
|
||||||
if (changed) {
|
|
||||||
d.inner.name = name;
|
|
||||||
d.inner.port = port;
|
|
||||||
d.inner.addresses = addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
return@addOrUpdateCastDevice changed;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun addOrUpdateCastDevice(
|
|
||||||
name: String,
|
|
||||||
deviceFactory: () -> CastingDevice,
|
|
||||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
|
||||||
) {
|
|
||||||
var invokeEvents: (() -> Unit)? = null;
|
|
||||||
|
|
||||||
synchronized(devices) {
|
|
||||||
val device = devices[name];
|
|
||||||
if (device != null) {
|
|
||||||
val changed = deviceUpdater(device);
|
|
||||||
if (changed) {
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceChanged.emit(device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val newDevice = deviceFactory();
|
|
||||||
this.devices[name] = newDevice
|
|
||||||
|
|
||||||
invokeEvents = {
|
|
||||||
onDeviceAdded.emit(newDevice);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastNetworkConfig(
|
|
||||||
val name: String,
|
|
||||||
val addresses: List<String>,
|
|
||||||
val services: List<FCastService>
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class FCastService(
|
|
||||||
val port: Int,
|
|
||||||
val type: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG = "StateCastingLegacy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.futo.platformplayer.casting.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlayMessage(
|
|
||||||
val container: String,
|
|
||||||
val url: String? = null,
|
|
||||||
val content: String? = null,
|
|
||||||
val time: Double? = null,
|
|
||||||
val speed: Double? = null
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSeekMessage(
|
|
||||||
val time: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val time: Double,
|
|
||||||
val duration: Double,
|
|
||||||
val state: Int,
|
|
||||||
val speed: Double
|
|
||||||
) { }
|
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVolumeUpdateMessage(
|
|
||||||
val generationTime: Long,
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetVolumeMessage(
|
|
||||||
val volume: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastSetSpeedMessage(
|
|
||||||
val speed: Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastPlaybackErrorMessage(
|
|
||||||
val message: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastVersionMessage(
|
|
||||||
val version: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastKeyExchangeMessage(
|
|
||||||
val version: Long,
|
|
||||||
val publicKey: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastDecryptedMessage(
|
|
||||||
val opcode: Long,
|
|
||||||
val message: String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class FCastEncryptedMessage(
|
|
||||||
val version: Long,
|
|
||||||
val iv: String?,
|
|
||||||
val blob: String
|
|
||||||
)
|
|
||||||
@@ -71,16 +71,14 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
|||||||
fun emit() : Boolean {
|
fun emit() : Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke();
|
handled = handled || conditional.handler.invoke();
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke();
|
handler.handler.invoke();
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -88,16 +86,15 @@ class Event0() : EventBase<(()->Unit), (()->Boolean)>() {
|
|||||||
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
class Event1<T1>() : EventBase<((T1)->Unit), ((T1)->Boolean)>() {
|
||||||
fun emit(value : T1): Boolean {
|
fun emit(value : T1): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
synchronized(_conditionalListeners) {
|
|
||||||
for (conditional in _conditionalListeners)
|
|
||||||
handled = handled || conditional.handler.invoke(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
for (conditional in condSnapshot)
|
||||||
for (handler in _listeners)
|
handled = handled || conditional.handler.invoke(value);
|
||||||
handler.handler.invoke(value);
|
|
||||||
}
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
|
handled = handled || snapshot.isNotEmpty();
|
||||||
|
for (handler in snapshot)
|
||||||
|
handler.handler.invoke(value);
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -106,16 +103,14 @@ class Event2<T1, T2>() : EventBase<((T1, T2)->Unit), ((T1, T2)->Boolean)>() {
|
|||||||
fun emit(value1 : T1, value2 : T2): Boolean {
|
fun emit(value1 : T1, value2 : T2): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke(value1, value2);
|
handled = handled || conditional.handler.invoke(value1, value2);
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke(value1, value2);
|
handler.handler.invoke(value1, value2);
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
@@ -125,16 +120,14 @@ class Event3<T1, T2, T3>() : EventBase<((T1, T2, T3)->Unit), ((T1, T2, T3)->Bool
|
|||||||
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
|
fun emit(value1 : T1, value2 : T2, value3 : T3): Boolean {
|
||||||
var handled = false;
|
var handled = false;
|
||||||
|
|
||||||
synchronized(_conditionalListeners) {
|
val condSnapshot = synchronized(_conditionalListeners) { _conditionalListeners.toList() };
|
||||||
for (conditional in _conditionalListeners)
|
for (conditional in condSnapshot)
|
||||||
handled = handled || conditional.handler.invoke(value1, value2, value3);
|
handled = handled || conditional.handler.invoke(value1, value2, value3);
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(_listeners) {
|
val snapshot = synchronized(_listeners) { _listeners.toList() };
|
||||||
handled = handled || _listeners.isNotEmpty();
|
handled = handled || snapshot.isNotEmpty();
|
||||||
for (handler in _listeners)
|
for (handler in snapshot)
|
||||||
handler.handler.invoke(value1, value2, value3);
|
handler.handler.invoke(value1, value2, value3);
|
||||||
}
|
|
||||||
|
|
||||||
return handled;
|
return handled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.dev.V8RemoteObject
|
|||||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.gsonStandard
|
||||||
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
import com.futo.platformplayer.engine.dev.V8RemoteObject.Companion.serialize
|
||||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.LoginFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateAssets
|
import com.futo.platformplayer.states.StateAssets
|
||||||
@@ -28,6 +29,8 @@ import com.google.gson.FieldAttributes
|
|||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.lang.reflect.Field
|
import java.lang.reflect.Field
|
||||||
@@ -268,11 +271,17 @@ class DeveloperEndpoints(private val context: Context) {
|
|||||||
context.respondCode(403, "This plugin doesn't support auth");
|
context.respondCode(403, "This plugin doesn't support auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||||
|
LoginFragment.showLogin(config){
|
||||||
|
_testPluginVariables.clear();
|
||||||
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/*
|
||||||
LoginActivity.showLogin(StateApp.instance.context, config) {
|
LoginActivity.showLogin(StateApp.instance.context, config) {
|
||||||
_testPluginVariables.clear();
|
_testPluginVariables.clear();
|
||||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||||
|
}; */
|
||||||
};
|
|
||||||
context.respondCode(200, "Login started");
|
context.respondCode(200, "Login started");
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
catch(ex: Throwable) {
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import android.widget.Button
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateDownloadService
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.copyToOutputStream
|
import com.futo.platformplayer.copyToOutputStream
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -34,6 +37,8 @@ import java.io.InputStream
|
|||||||
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutoUpdateDialog";
|
private val TAG = "AutoUpdateDialog";
|
||||||
|
|
||||||
|
var currentDialog: AutoUpdateDialog? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var _buttonNever: Button;
|
private lateinit var _buttonNever: Button;
|
||||||
@@ -46,7 +51,6 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
private var _maxVersion: Int = 0;
|
private var _maxVersion: Int = 0;
|
||||||
|
|
||||||
private var _updating: Boolean = false;
|
private var _updating: Boolean = false;
|
||||||
private var _apkFile: File? = null;
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
@@ -61,12 +65,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
_buttonShowChangelog = findViewById(R.id.button_show_changelog);
|
||||||
|
|
||||||
_buttonNever.setOnClickListener {
|
_buttonNever.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
Settings.instance.autoUpdate.check = 1;
|
Settings.instance.autoUpdate.check = 1;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
_buttonClose.setOnClickListener {
|
_buttonClose.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,23 +82,32 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_buttonUpdate.setOnClickListener {
|
_buttonUpdate.setOnClickListener {
|
||||||
|
UpdateNotificationManager.cancelAll(context)
|
||||||
|
|
||||||
if (_updating) {
|
if (_updating) {
|
||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updating = true;
|
if (Settings.instance.autoUpdate.shouldBackgroundDownload) {
|
||||||
update();
|
val ctx = context.applicationContext;
|
||||||
|
val intent = Intent(ctx, UpdateDownloadService::class.java);
|
||||||
|
intent.putExtra(UpdateDownloadService.EXTRA_VERSION, _maxVersion);
|
||||||
|
ContextCompat.startForegroundService(ctx, intent);
|
||||||
|
UIDialogs.toast(context, "Downloading update in background");
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
_updating = true;
|
||||||
|
update();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
fun showPredownloaded(apkFile: File) {
|
currentDialog = this
|
||||||
_apkFile = apkFile;
|
|
||||||
super.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
override fun dismiss() {
|
||||||
super.dismiss()
|
super.dismiss()
|
||||||
InstallReceiver.onReceiveResult.clear();
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
currentDialog = null
|
||||||
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
Logger.i(TAG, "Cleared InstallReceiver.onReceiveResult handler.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +133,14 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
|
|||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var inputStream: InputStream? = null;
|
var inputStream: InputStream? = null;
|
||||||
try {
|
try {
|
||||||
val apkFile = _apkFile;
|
val client = ManagedHttpClient();
|
||||||
if (apkFile != null) {
|
val response = client.get(StateUpdate.getApkUrl(_maxVersion));
|
||||||
inputStream = apkFile.inputStream();
|
if (response.isOk && response.body != null) {
|
||||||
val dataLength = apkFile.length();
|
inputStream = response.body.byteStream();
|
||||||
|
val dataLength = response.body.contentLength();
|
||||||
install(inputStream, dataLength);
|
install(inputStream, dataLength);
|
||||||
} else {
|
} else {
|
||||||
val client = ManagedHttpClient();
|
throw Exception("Failed to download latest version of app.");
|
||||||
val response = client.get(StateUpdate.APK_URL);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
inputStream = response.body.byteStream();
|
|
||||||
val dataLength = response.body.contentLength();
|
|
||||||
install(inputStream, dataLength);
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to download latest version of app.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
Logger.w(TAG, "Exception thrown while downloading and installing latest version of app.", e);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.futo.platformplayer.dialogs
|
package com.futo.platformplayer.dialogs
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
@@ -11,88 +15,88 @@ import com.futo.platformplayer.R
|
|||||||
import com.futo.platformplayer.Settings
|
import com.futo.platformplayer.Settings
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StateBackup
|
import com.futo.platformplayer.states.StateBackup
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
|
||||||
|
|
||||||
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
||||||
private lateinit var _buttonStart: LinearLayout;
|
private lateinit var _buttonStart: LinearLayout
|
||||||
private lateinit var _buttonStop: LinearLayout;
|
private lateinit var _buttonStop: LinearLayout
|
||||||
private lateinit var _buttonCancel: ImageButton;
|
private lateinit var _buttonCancel: ImageButton
|
||||||
|
private lateinit var _imm: InputMethodManager
|
||||||
private lateinit var _editPassword: EditText;
|
|
||||||
private lateinit var _editPassword2: EditText;
|
|
||||||
|
|
||||||
private lateinit var _inputMethodManager: InputMethodManager;
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null));
|
setContentView(LayoutInflater.from(context).inflate(R.layout.dialog_automatic_backup, null))
|
||||||
|
|
||||||
_buttonCancel = findViewById(R.id.button_cancel);
|
_buttonCancel = findViewById(R.id.button_cancel)
|
||||||
_buttonStop = findViewById(R.id.button_stop);
|
_buttonStop = findViewById(R.id.button_stop)
|
||||||
_buttonStart = findViewById(R.id.button_start);
|
_buttonStart = findViewById(R.id.button_start)
|
||||||
_editPassword = findViewById(R.id.edit_password);
|
|
||||||
_editPassword2 = findViewById(R.id.edit_password2);
|
|
||||||
|
|
||||||
_inputMethodManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager;
|
_imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
_buttonStart.visibility = if (Settings.instance.backup.autoBackupEnabled) View.GONE else View.VISIBLE
|
||||||
|
_buttonStop.visibility = if (Settings.instance.backup.autoBackupEnabled) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
_buttonCancel.setOnClickListener {
|
_buttonCancel.setOnClickListener {
|
||||||
clearFocus();
|
dismiss()
|
||||||
dismiss();
|
}
|
||||||
};
|
|
||||||
_buttonStop.setOnClickListener {
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
Settings.instance.backup.autoBackupPassword = null;
|
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
|
||||||
Settings.instance.save();
|
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup disabled");
|
_buttonStop.setOnClickListener {
|
||||||
|
dismiss()
|
||||||
|
Settings.instance.backup.autoBackupEnabled = false
|
||||||
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_disabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonStart.setOnClickListener {
|
_buttonStart.setOnClickListener {
|
||||||
val p1 = _editPassword.text.toString();
|
dismiss()
|
||||||
val p2 = _editPassword2.text.toString();
|
Logger.i(TAG, "Enable AutoBackup (unencrypted)")
|
||||||
if(!(p1?.equals(p2) ?: false)) {
|
|
||||||
UIDialogs.toast(context, "Password fields do not match, confirm that you typed it correctly.");
|
val activity = StateApp.instance.activity as? Activity
|
||||||
return@setOnClickListener;
|
if (activity == null) {
|
||||||
|
UIDialogs.toast(context, "No activity available")
|
||||||
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
val pbytes = _editPassword.text.toString().toByteArray();
|
dismiss()
|
||||||
if(pbytes.size < 4 || pbytes.size > 32) {
|
|
||||||
UIDialogs.toast(context, "Password needs to be atleast 4 bytes long and smaller than 32 bytes", false);
|
|
||||||
return@setOnClickListener;
|
|
||||||
}
|
|
||||||
clearFocus();
|
|
||||||
dismiss();
|
|
||||||
|
|
||||||
Logger.i(TAG, "Set AutoBackupPassword");
|
Logger.i(TAG, "Enable AutoBackup")
|
||||||
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
Settings.instance.backup.didAskAutoBackup = true;
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
Settings.instance.save();
|
Settings.instance.save()
|
||||||
|
|
||||||
UIDialogs.toast(context, "AutoBackup enabled");
|
UIDialogs.toast(context, "AutoBackup enabled")
|
||||||
try {
|
try {
|
||||||
StateBackup.startAutomaticBackup(true);
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
catch(ex: Throwable) {
|
|
||||||
Logger.e(TAG, "Forced automatic backup failed", ex);
|
Settings.instance.backup.autoBackupEnabled = true
|
||||||
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message);
|
Settings.instance.backup.autoBackupPassword = null
|
||||||
|
Settings.instance.backup.didAskAutoBackup = true
|
||||||
|
Settings.instance.save()
|
||||||
|
|
||||||
|
StateAnnouncement.instance.deleteAnnouncement("backup")
|
||||||
|
|
||||||
|
UIDialogs.toast(context, context.getString(R.string.automatic_backup_enabled))
|
||||||
|
try {
|
||||||
|
StateBackup.startAutomaticBackup(true)
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.e(TAG, "Forced automatic backup failed", ex)
|
||||||
|
UIDialogs.toast(context, "Automatic backup failed due to:\n" + ex.message)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
|
window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearFocus() {
|
|
||||||
_editPassword.clearFocus();
|
|
||||||
currentFocus?.let { _inputMethodManager.hideSoftInputFromWindow(it.windowToken, 0) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "AutomaticBackupDialog";
|
private const val TAG = "AutomaticBackupDialog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user