mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 19429263a9 | |||
| 986652adab | |||
| 4d93a58d5d | |||
| 817c90f3af | |||
| 77348b3787 | |||
| 31e26d03c6 | |||
| 1ef566ab16 | |||
| 7597f5136c | |||
| 9a2a70622f | |||
| 4fc33411fd | |||
| a9bb900994 | |||
| 8c1a18d8b4 | |||
| 14ae5f1572 | |||
| ed40994600 | |||
| 90e8c35b19 | |||
| 4d017ad357 | |||
| 2ca2a9db23 | |||
| 713d46c781 | |||
| 0429665173 | |||
| ac05edca77 | |||
| ad3dacf68f | |||
| 91a8996c11 | |||
| 40c4a51a2b | |||
| f8e0aaf4d2 | |||
| ad97b5a406 | |||
| b0e0c1b75f | |||
| b1fce443e9 | |||
| 66f8711055 | |||
| b7c123c281 | |||
| 9481bbf3f1 | |||
| 43ec7e821b | |||
| ca3454afbe | |||
| 7c70e58129 | |||
| 1edc8aabf8 | |||
| 91060faac9 | |||
| 17027ba364 | |||
| 8569eaa5db | |||
| d32d817e0a | |||
| a0f4cc760c | |||
| 5247997ea5 | |||
| 453030d561 | |||
| e080702a52 | |||
| 3909343adc | |||
| dc76934d0e | |||
| 6cf47d592a | |||
| 1507c70729 | |||
| d6a23ac0de | |||
| 17df396672 | |||
| 0c5ba0cd39 | |||
| 183aeb18a0 | |||
| 8d08e19cd2 | |||
| a882d04d26 | |||
| c4d06c1ba2 | |||
| 4dfcd47901 | |||
| 4c0c1abb4b | |||
| 6f44071186 | |||
| 29910a2698 | |||
| b5da0d4462 | |||
| 99fb9b3462 | |||
| 5f0a89d13b | |||
| f311561e6f | |||
| 2fc944ddd9 | |||
| a2970b86ee | |||
| ac9a51f105 | |||
| 90dca2537a | |||
| 4df227147c | |||
| 1fb55dca0a | |||
| 3d7b347e49 | |||
| 769ec9f59a | |||
| dee310de3d | |||
| 0af4bad906 | |||
| 4731673ba3 | |||
| 8745221cbd | |||
| 742d95440e | |||
| 180b320cd7 | |||
| cc8dffc485 | |||
| a64fd2cf35 | |||
| 4aceb364d9 | |||
| 76d9bac0ec | |||
| 2b8dc41d0d | |||
| 33430c538c | |||
| 03e9cb398b | |||
| 2aef2ebec1 | |||
| 5e5fffbf97 | |||
| 51ac604e31 | |||
| 4e49b5bc63 | |||
| 658cbc5e00 | |||
| 2ceb4c5644 | |||
| 940bed2cee | |||
| 2738954af7 | |||
| db5aaf0b84 | |||
| e1abb7f8ae | |||
| 3310ac6008 | |||
| 09879c83e9 | |||
| 7aa8b6bc14 | |||
| cac8a8fde4 | |||
| 82f214f155 | |||
| 4ee127fe13 | |||
| 1e4aefb7d5 | |||
| 2a825a9f83 | |||
| 6695774037 | |||
| a10bc8c7de | |||
| c1e6e401cc | |||
| 98b6213886 | |||
| b6671c653c | |||
| 55d042bee3 | |||
| 80034ad131 | |||
| 4eb20a1843 | |||
| 98c6378148 | |||
| bb066a7a31 | |||
| b5d3261f03 | |||
| 30c41044da | |||
| e369676808 | |||
| 2fa9e65bee | |||
| cf96bd1ec0 | |||
| 1f5a069877 | |||
| adc5013ea4 | |||
| 515c5e00e9 | |||
| ba9f843368 | |||
| 0653f88c49 | |||
| 4ce9f64808 | |||
| 755bebaecb | |||
| 4fa0229ccb | |||
| 42dd8d6152 | |||
| 0a839b4814 | |||
| 586db317dd | |||
| ae36a24ad1 | |||
| 9a435f8859 | |||
| 81162c5df2 | |||
| c7c3ddfc96 | |||
| 830d3a9022 | |||
| a1c2d19daf | |||
| 004e4be4d3 | |||
| bd87a47551 | |||
| 76103a2a8c | |||
| f63f9dd6db |
@@ -1,2 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ body:
|
||||
label: Reproduction steps
|
||||
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||
placeholder: |
|
||||
0. Play a Youtube video
|
||||
0. Play a YouTube video
|
||||
1. Press on Download button
|
||||
2. Select quality 1440p
|
||||
3. Grayjay crashes when attempting to download
|
||||
@@ -83,7 +83,7 @@ body:
|
||||
- "Spotify"
|
||||
- "TedTalks"
|
||||
- "Twitch"
|
||||
- "Youtube"
|
||||
- "YouTube"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
+6
-6
@@ -64,12 +64,6 @@
|
||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||
path = app/src/stable/assets/sources/bilibili
|
||||
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"]
|
||||
path = app/src/stable/assets/sources/bitchute
|
||||
url = ../plugins/bitchute.git
|
||||
@@ -106,3 +100,9 @@
|
||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||
path = app/src/stable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
[submodule "app/src/stable/assets/sources/mixcloud"]
|
||||
path = app/src/stable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||
path = app/src/unstable/assets/sources/mixcloud
|
||||
url = ../plugins/mixcloud.git
|
||||
|
||||
+52
-46
@@ -1,8 +1,8 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
|
||||
id 'org.ajoberstar.grgit' version '5.3.3'
|
||||
id 'com.google.protobuf'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.google.devtools.ksp'
|
||||
@@ -39,7 +39,7 @@ protobuf {
|
||||
|
||||
android {
|
||||
namespace 'com.futo.platformplayer'
|
||||
compileSdk 34
|
||||
compileSdk 36
|
||||
flavorDimensions "buildType"
|
||||
productFlavors {
|
||||
stable {
|
||||
@@ -97,7 +97,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk 28
|
||||
targetSdk 34
|
||||
targetSdk 36
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
@@ -146,6 +146,7 @@ android {
|
||||
}
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||
assets {
|
||||
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||
}
|
||||
@@ -154,80 +155,85 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.7.2'
|
||||
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
//implementation 'com.google.dagger:dagger:2.48'
|
||||
implementation 'androidx.test:monitor:1.8.0'
|
||||
implementation 'com.google.android.material:material:1.13.0'
|
||||
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||
|
||||
//Core
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.17.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation 'androidx.documentfile:documentfile:1.1.0'
|
||||
|
||||
//Images
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
||||
implementation 'com.github.bumptech.glide:glide:5.0.5'
|
||||
|
||||
//Async
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
||||
|
||||
//HTTP
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||
|
||||
//JSON
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //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 "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
|
||||
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||
|
||||
//JS
|
||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
||||
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
|
||||
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
|
||||
|
||||
//Exoplayer
|
||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
|
||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
|
||||
implementation 'androidx.media3:media3-transformer:1.8.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||
implementation 'androidx.media:media:1.7.1'
|
||||
|
||||
//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 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
|
||||
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.caverock:androidsvg-aar:1.4'
|
||||
|
||||
//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.futo.futopay:app:1.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.11.0'
|
||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
|
||||
|
||||
//Database
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
implementation("androidx.room:room-runtime:2.8.3")
|
||||
ksp("androidx.room:room-compiler:2.8.3")
|
||||
implementation("androidx.room:room-ktx:2.8.3")
|
||||
|
||||
//Payment
|
||||
implementation 'com.stripe:stripe-android:20.35.1'
|
||||
implementation 'com.stripe:stripe-android:22.0.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
|
||||
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
|
||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
|
||||
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
|
||||
testImplementation "org.mockito:mockito-core:5.20.0"
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
<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.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -153,30 +156,30 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".activities.TestActivity"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.SettingsActivity"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.DeveloperActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.ExceptionActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.CaptchaActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.LoginActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.AddSourceActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -189,54 +192,62 @@
|
||||
<activity
|
||||
android:name=".activities.AddSourceOptionsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricHomeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricBackupActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricCreateProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricWhyActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.PolycentricImportProfileActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.ManageTabsActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.QRCaptureActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.FCastGuideActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.SyncHomeActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.SyncPairActivity"
|
||||
android:screenOrientation="sensorPortrait"
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||
<activity
|
||||
android:name=".activities.SyncShowPairingCodeActivity"
|
||||
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" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1022,15 +1022,38 @@
|
||||
return x.value
|
||||
});
|
||||
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
const settings = this.Plugin?.currentPlugin?.settings;
|
||||
if (settings) {
|
||||
for (let setting of settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
parameterVals[1] = settingsToUse;
|
||||
else
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
parameterVals.push(settingsToUse);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
|
||||
@@ -67,6 +67,7 @@ class ScriptException extends Error {
|
||||
super(arguments[0]);
|
||||
this.plugin_type = "ScriptException";
|
||||
this.message = arguments[0];
|
||||
this.msg = arguments[0];
|
||||
}
|
||||
else {
|
||||
super(msg);
|
||||
@@ -251,6 +252,9 @@ class PlatformVideo extends PlatformContent {
|
||||
this.duration = obj.duration ?? -1; //Long
|
||||
this.viewCount = obj.viewCount ?? -1; //Long
|
||||
|
||||
this.playbackTime = obj.playbackTime ?? -1;
|
||||
this.playbackDate = obj.playbackDate ?? undefined;
|
||||
|
||||
this.isLive = obj.isLive ?? false; //Boolean
|
||||
this.isShort = !!obj.isShort ?? false;
|
||||
}
|
||||
@@ -464,14 +468,20 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
||||
this.getLicenseRequestExecutor = () => {
|
||||
return {
|
||||
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||
return http.POST(
|
||||
const response = http.POST(
|
||||
url,
|
||||
license_request_data,
|
||||
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||
false,
|
||||
true
|
||||
).body
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.body) {
|
||||
throw new ScriptException("Unable to acquire license key");
|
||||
}
|
||||
|
||||
return response.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,6 +795,7 @@ let plugin = {
|
||||
//To override by plugin
|
||||
const source = {
|
||||
getHome() { return new ContentPager([], false, {}); },
|
||||
getShorts() { return new VideoPager([], false, {}); },
|
||||
|
||||
enable(config){ },
|
||||
disable() {},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket? {
|
||||
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
|
||||
ensureNotMainThread()
|
||||
|
||||
val timeout = 10000
|
||||
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||
if(addresses.isEmpty())
|
||||
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||
@@ -232,7 +231,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): Socket?
|
||||
val socket = Socket()
|
||||
|
||||
try {
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
|
||||
} catch (e: Throwable) {
|
||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||
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) {
|
||||
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.V8ValueObject
|
||||
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.V8Plugin
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
@@ -21,7 +24,6 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.selects.SelectClause0
|
||||
import kotlinx.coroutines.selects.SelectClause1
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
@@ -194,7 +196,6 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
val latch = CountDownLatch(1);
|
||||
var promiseResult: T? = null;
|
||||
@@ -204,16 +205,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
if(p0 is V8ValueError)
|
||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||
else
|
||||
else {
|
||||
if(p0 is V8ValueObject)
|
||||
p0.setWeak();
|
||||
promiseResult = p0 as T;
|
||||
}
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onRejected promise not implemented.."));
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onCatch promise not implemented.."));
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
@@ -223,8 +227,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
promiseException = CancellationException("Cancelled by system");
|
||||
latch.countDown();
|
||||
}
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
//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());
|
||||
|
||||
|
||||
if(!promise.isPending) {
|
||||
try {
|
||||
Logger.i("V8", "V8Promise resolved synchronously");
|
||||
if(promise.isFulfilled)
|
||||
promiseResult = promise.getResult<T>();
|
||||
else
|
||||
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
promiseException = ex;
|
||||
}
|
||||
}
|
||||
else {
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
}
|
||||
}
|
||||
if(promiseException != null)
|
||||
throw promiseException!!;
|
||||
@@ -249,12 +270,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
||||
underlyingDef.complete(p0 as T);
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
|
||||
try {
|
||||
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?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,6 +299,23 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
||||
return def;
|
||||
}
|
||||
|
||||
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||
val p0 = this;
|
||||
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 msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||
return Throwable("Promise Failed: " + pluginType + msg);
|
||||
*/
|
||||
}
|
||||
else if(p0 is V8ValueString)
|
||||
return Throwable("Promise Failed:" + p0.value);
|
||||
else
|
||||
return NotImplementedError("onCatch promise not implemented..");
|
||||
}
|
||||
|
||||
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||
|
||||
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||
@@ -325,4 +376,27 @@ fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferre
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.activities.SyncHomeActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
@@ -25,6 +25,7 @@ import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
@@ -34,13 +35,13 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
@@ -62,7 +63,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
||||
@FormFieldButton(R.drawable.ic_update)
|
||||
fun syncGrayjay() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
||||
}
|
||||
}
|
||||
@@ -71,7 +72,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||
@FormFieldButton(R.drawable.ic_person)
|
||||
fun managePolycentricIdentity() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
if (StatePolycentric.instance.processHandle != null) {
|
||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||
@@ -89,7 +90,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun openFAQ() {
|
||||
try {
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
||||
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
@@ -99,7 +100,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun openIssues() {
|
||||
try {
|
||||
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) {
|
||||
//Ignored
|
||||
}
|
||||
@@ -130,7 +131,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormFieldButton(R.drawable.ic_tabs)
|
||||
fun manageTabs() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp?.instance?.activity?.let {
|
||||
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -143,7 +144,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||
@FormFieldButton(R.drawable.ic_move_up)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
val act = StateApp.instance.activity ?: return;
|
||||
val intent = MainActivity.getImportOptionsIntent(act);
|
||||
act.startActivity(intent);
|
||||
}
|
||||
@@ -152,7 +153,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormFieldButton(R.drawable.ic_link)
|
||||
fun manageLinks() {
|
||||
try {
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show url handling prompt", e)
|
||||
}
|
||||
@@ -161,7 +162,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)
|
||||
@FormFieldButton(R.drawable.battery_full_24px)
|
||||
fun ignoreBatteryOptimization() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
val intent = Intent()
|
||||
val packageName = it.packageName
|
||||
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||
@@ -201,6 +202,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
8 -> "zh";
|
||||
9 -> "ru";
|
||||
10 -> "ar";
|
||||
11 -> "it";
|
||||
12 -> "tr";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -240,7 +243,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun clearHidden() {
|
||||
StateMeta.instance.removeAllHiddenCreators();
|
||||
StateMeta.instance.removeAllHiddenVideos();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||
}
|
||||
}
|
||||
@@ -370,9 +373,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||
fun clearChannelCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
|
||||
StateCache.instance.clear();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +407,10 @@ class Settings : FragmentedStorageFileJson() {
|
||||
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)
|
||||
var preferOriginalAudio: Boolean = true;
|
||||
|
||||
@@ -422,6 +429,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
6 -> 1.75f;
|
||||
7 -> 2.0f;
|
||||
8 -> 2.25f;
|
||||
9 -> 2.5f;
|
||||
10 -> 2.75f;
|
||||
11 -> 3.0f;
|
||||
else -> 1.0f;
|
||||
};
|
||||
|
||||
@@ -603,6 +613,16 @@ class Settings : FragmentedStorageFileJson() {
|
||||
else -> 2.0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||
var shortsPregenerate: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
|
||||
@FormFieldWarning(R.string.shorts_fit_video_warning)
|
||||
var shortsFitVideo: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -705,6 +725,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
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 = true
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
@@ -737,7 +762,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
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) {
|
||||
@@ -754,7 +779,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||
fun 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)); };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,13 +847,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
||||
fun changeStorageGeneral() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||
}
|
||||
}
|
||||
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
||||
fun changeStorageDownload() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
@@ -837,7 +862,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun clearStorageDownload() {
|
||||
Settings.instance.storage.storage_download = null;
|
||||
Settings.instance.save();
|
||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,13 +899,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||
fun manualCheck() {
|
||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
StateUpdate.instance.checkForUpdates(it, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
try {
|
||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
@@ -892,7 +917,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||
fun viewChangelog() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
@@ -932,7 +957,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
class Backup {
|
||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||
var didAskAutoBackup: Boolean = false;
|
||||
var didAskAutoBackup: Boolean = true;
|
||||
var autoBackupPassword: String? = null;
|
||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
||||
|
||||
@@ -941,13 +966,13 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||
fun configureAutomaticBackup() {
|
||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
||||
SettingsActivity.getActivity()?.reloadSettings();
|
||||
UIDialogs.showAutomaticBackupDialog(StateApp.instance.activity!!, autoBackupPassword != null) {
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
};
|
||||
}
|
||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||
fun restoreAutomaticBackup() {
|
||||
val activity = SettingsActivity.getActivity()!!
|
||||
val activity = StateApp.instance.activity!!
|
||||
|
||||
if(!StateBackup.hasAutomaticBackup())
|
||||
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
||||
@@ -958,8 +983,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)
|
||||
fun export() {
|
||||
val activity = SettingsActivity.getActivity() ?: return;
|
||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
||||
val activity = StateApp.instance.activity ?: return;
|
||||
val fragView = SettingsFragment.currentView ?: return;
|
||||
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
|
||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||
StateBackup.shareExternalBackup();
|
||||
}),
|
||||
@@ -975,11 +1001,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable
|
||||
class Payment {
|
||||
@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)
|
||||
fun viewLicenseStatus() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
try {
|
||||
if (StatePayment.instance.hasPaid) {
|
||||
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||
@@ -995,12 +1021,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||
fun clearPayment() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
StateApp.instance.activity?.let { context ->
|
||||
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.activity?.let {
|
||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||
it.reloadSettings();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1087,6 +1113,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||
var localConnections: Boolean = true;
|
||||
|
||||
|
||||
|
||||
var syncServerUrl: String? = null;
|
||||
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
|
||||
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||
fun configureSyncServer() {
|
||||
StateApp.instance.activity?.let { context ->
|
||||
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||
"Enter the url to your relay server",
|
||||
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||
null,
|
||||
syncServerUrl ?: "",
|
||||
"YourRelayServerDomain.com", 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Reset", {
|
||||
syncServerUrl = null;
|
||||
instance.save();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action.withInput("Configure", {
|
||||
syncServerUrl = it?.text
|
||||
instance.save();
|
||||
SettingsFragment.currentView?.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||
|
||||
@@ -8,9 +8,7 @@ import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.DeveloperActivity
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
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.background.BackgroundWorker
|
||||
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.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
@@ -97,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
fun subscriptionsCache5000() {
|
||||
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"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)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
@@ -121,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
@@ -152,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
fun historyCache100() {
|
||||
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"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)
|
||||
button.setButtonEnabled(false);
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
@@ -186,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val diff = System.currentTimeMillis() - lastToast;
|
||||
lastToast = System.currentTimeMillis();
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(
|
||||
SettingsActivity.getActivity()!!,
|
||||
StateApp.instance.activity!!,
|
||||
"FINISHED Page: ${page}, Total: ${total}"
|
||||
);
|
||||
}
|
||||
@@ -235,9 +235,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
val act = StateApp.instance.activity!!;
|
||||
try {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
@@ -251,9 +251,9 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 4)
|
||||
fun clearChannelContentCache() {
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
|
||||
StateCache.instance.clearToday();
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
||||
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ class UIDialogs {
|
||||
currentDialog.code,
|
||||
currentDialog.defaultCloseAction,
|
||||
*currentDialog.actions.map {
|
||||
return@map Action(it.text, {
|
||||
it.action();
|
||||
return@map Action.withInput(it.text, { str ->
|
||||
it.invokeAction(str);
|
||||
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
||||
}, it.style);
|
||||
}.toTypedArray());
|
||||
@@ -203,7 +203,9 @@ class UIDialogs {
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||
}
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
|
||||
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
builder.setView(view);
|
||||
@@ -226,6 +228,16 @@ class UIDialogs {
|
||||
this.text = textDetails;
|
||||
}
|
||||
};
|
||||
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
|
||||
inputView.apply {
|
||||
if (input == null && placeholder == null) this.visibility = View.GONE;
|
||||
else {
|
||||
this.text = input ?: "";
|
||||
this.hint = placeholder ?: "";
|
||||
this.visibility = View.VISIBLE;
|
||||
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||
}
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if (code == null) this.visibility = View.GONE;
|
||||
else {
|
||||
@@ -250,7 +262,7 @@ class UIDialogs {
|
||||
buttonView.textSize = 14f;
|
||||
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
||||
buttonView.text = act.text;
|
||||
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
|
||||
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
|
||||
when(act.style) {
|
||||
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
||||
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
||||
@@ -275,7 +287,7 @@ class UIDialogs {
|
||||
};
|
||||
dialog.setOnCancelListener {
|
||||
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
||||
actions[defaultCloseAction].action();
|
||||
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
registerDialogClosed(dialog);
|
||||
@@ -424,7 +436,7 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showCastingDialog(context: Context) {
|
||||
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d != null) {
|
||||
val dialog = ConnectedCastingDialog(context);
|
||||
@@ -432,6 +444,7 @@ class UIDialogs {
|
||||
dialog.setOwnerActivity(context)
|
||||
}
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
} else {
|
||||
@@ -444,21 +457,24 @@ class UIDialogs {
|
||||
if (c is Activity) {
|
||||
dialog.setOwnerActivity(c);
|
||||
}
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
fun showCastingTutorialDialog(context: Context) {
|
||||
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingHelpDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
fun showCastingAddDialog(context: Context) {
|
||||
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||
val dialog = CastingAddDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
}
|
||||
@@ -531,17 +547,36 @@ class UIDialogs {
|
||||
}
|
||||
class Action {
|
||||
val text: String;
|
||||
val action: ()->Unit;
|
||||
val action: ((DialogResult?)->Unit);
|
||||
val style: ActionStyle;
|
||||
var center: Boolean;
|
||||
|
||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = { action() };
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = action;
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
|
||||
fun invokeAction(input: DialogResult? = null) {
|
||||
this.action(input);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
|
||||
return Action(text, action, style, center);
|
||||
}
|
||||
}
|
||||
}
|
||||
class DialogResult(
|
||||
val text: String?
|
||||
);
|
||||
enum class ActionStyle {
|
||||
NONE,
|
||||
PRIMARY,
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
@@ -74,6 +73,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
@@ -129,115 +129,163 @@ class UISlideOverlays {
|
||||
val originalVideo = subscription.doFetchVideos;
|
||||
val originalPosts = subscription.doFetchPosts;
|
||||
|
||||
val menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, listOf());
|
||||
val menu = SlideUpMenuOverlay(
|
||||
container.context,
|
||||
container,
|
||||
"Subscription Settings",
|
||||
null,
|
||||
true,
|
||||
listOf()
|
||||
);
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
|
||||
val capabilities = plugin.getChannelCapabilities();
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuGroup(container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()) else null,
|
||||
if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups = ArrayList<SubscriptionGroup>(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? = null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if(it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup = StateSubscriptionGroups.instance.getSubscriptionGroup(it.id)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if(it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
items.addAll(
|
||||
listOf(
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_notifications,
|
||||
"Notifications",
|
||||
"",
|
||||
tag = "notifications",
|
||||
call = {
|
||||
subscription.doNotifications =
|
||||
menu?.selectOption(null, "notifications", true, true)
|
||||
?: subscription.doNotifications;
|
||||
},
|
||||
invokeParent = false
|
||||
),
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Subscription Groups",
|
||||
"You can select which groups this subscription is part of.",
|
||||
-1, listOf()
|
||||
) else null,
|
||||
if (StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.isNotEmpty()
|
||||
)
|
||||
SlideUpMenuRecycler(container.context, "as") {
|
||||
val groups =
|
||||
ArrayList<SubscriptionGroup>(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
var adapter: AnyAdapterView<SubscriptionGroup, SubscriptionGroupBarViewHolder>? =
|
||||
null;
|
||||
adapter = it.asAny(groups, RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe {
|
||||
if (it is SubscriptionGroup.Selectable) {
|
||||
val actualGroup =
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroup(
|
||||
it.id
|
||||
)
|
||||
?: return@subscribe;
|
||||
groups.clear();
|
||||
if (it.selected)
|
||||
actualGroup.urls.remove(subscription.channel.url);
|
||||
else
|
||||
actualGroup.urls.add(subscription.channel.url);
|
||||
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(actualGroup);
|
||||
groups.addAll(StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map { SubscriptionGroup.Selectable(it, it.urls.contains(subscription.channel.url)) }
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()),
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
StateSubscriptionGroups.instance.updateSubscriptionGroup(
|
||||
actualGroup
|
||||
);
|
||||
groups.addAll(
|
||||
StateSubscriptionGroups.instance.getSubscriptionGroups()
|
||||
.map {
|
||||
SubscriptionGroup.Selectable(
|
||||
it,
|
||||
it.urls.contains(subscription.channel.url)
|
||||
)
|
||||
}
|
||||
.sortedBy { !it.selected });
|
||||
adapter?.notifyContentChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
return@SlideUpMenuRecycler adapter;
|
||||
} else null,
|
||||
SlideUpMenuGroup(
|
||||
container.context, "Fetch Settings",
|
||||
"Depending on the platform you might not need to enable a type for it to be available.",
|
||||
-1, listOf()
|
||||
),
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_live_tv,
|
||||
"Livestreams",
|
||||
"Check for livestreams",
|
||||
tag = "fetchLive",
|
||||
call = {
|
||||
subscription.doFetchLive =
|
||||
menu?.selectOption(null, "fetchLive", true, true)
|
||||
?: subscription.doFetchLive;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Streams",
|
||||
"Check for streams",
|
||||
tag = "fetchStreams",
|
||||
call = {
|
||||
subscription.doFetchStreams =
|
||||
menu?.selectOption(null, "fetchStreams", true, true)
|
||||
?: subscription.doFetchStreams;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Videos",
|
||||
"Check for videos",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else if (capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_play,
|
||||
"Content",
|
||||
"Check for content",
|
||||
tag = "fetchVideos",
|
||||
call = {
|
||||
subscription.doFetchVideos =
|
||||
menu?.selectOption(null, "fetchVideos", true, true)
|
||||
?: subscription.doFetchVideos;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null,
|
||||
if (capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_chat,
|
||||
"Posts",
|
||||
"Check for posts",
|
||||
tag = "fetchPosts",
|
||||
call = {
|
||||
subscription.doFetchPosts =
|
||||
menu?.selectOption(null, "fetchPosts", true, true)
|
||||
?: subscription.doFetchPosts;
|
||||
},
|
||||
invokeParent = false
|
||||
) else null/*,,
|
||||
|
||||
SlideUpMenuGroup(container.context, "Actions",
|
||||
"Various things you can do with this subscription",
|
||||
@@ -245,61 +293,76 @@ class UISlideOverlays {
|
||||
SlideUpMenuItem(container.context, R.drawable.ic_list, "Add to Group", "", "btnAddToGroup", {
|
||||
showCreateSubscriptionGroup(container, subscription.channel);
|
||||
}, false)*/
|
||||
).filterNotNull());
|
||||
).filterNotNull()
|
||||
);
|
||||
|
||||
menu.setItems(items);
|
||||
menu.setItems(items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if(subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if(subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
if (subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if (subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if (subscription.doFetchStreams)
|
||||
menu.selectOption(null, "fetchStreams", true, true);
|
||||
if (subscription.doFetchVideos)
|
||||
menu.selectOption(null, "fetchVideos", true, true);
|
||||
if (subscription.doFetchPosts)
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
menu.onOK.subscribe {
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
|
||||
if(subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(container.context, "Enable 'Background Update' in settings for notifications to work");
|
||||
if (subscription.doNotifications && !originalNotif) {
|
||||
val mainContext = StateApp.instance.contextOrNull;
|
||||
if (Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval == 0) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Enable 'Background Update' in settings for notifications to work"
|
||||
);
|
||||
|
||||
if(mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(mainContext, R.drawable.ic_settings, "Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications", null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
val intent = Intent(mainContext, SettingsActivity::class.java);
|
||||
intent.putExtra("query", mainContext.getString(R.string.background_update));
|
||||
mainContext.startActivity(intent);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
return@subscribe;
|
||||
}
|
||||
else if(!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(container.context, "Android notifications are disabled");
|
||||
if(mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
if (mainContext is MainActivity) {
|
||||
UIDialogs.showDialog(
|
||||
mainContext,
|
||||
R.drawable.ic_settings,
|
||||
"Background Updating Required",
|
||||
"You need to set a Background Updating interval for notifications",
|
||||
null,
|
||||
0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Configure", {
|
||||
StateApp.instance.activity?.let {
|
||||
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
|
||||
}
|
||||
}, UIDialogs.ActionStyle.PRIMARY)
|
||||
);
|
||||
}
|
||||
return@subscribe;
|
||||
} else if (!(mainContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()) {
|
||||
UIDialogs.toast(
|
||||
container.context,
|
||||
"Android notifications are disabled"
|
||||
);
|
||||
if (mainContext is MainActivity) {
|
||||
mainContext.requestNotificationPermissions("Notifications are required for subscription updating and notifications to work");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
subscription.doNotifications = originalNotif;
|
||||
subscription.doFetchLive = originalLive;
|
||||
subscription.doFetchStreams = originalStream;
|
||||
subscription.doFetchVideos = originalVideo;
|
||||
subscription.doFetchPosts = originalPosts;
|
||||
};
|
||||
|
||||
menu.setOk("Save");
|
||||
menu.setOk("Save");
|
||||
|
||||
menu.show();
|
||||
menu.show();
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to show subscription overlay.", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ fun String.isHexColor(): Boolean {
|
||||
|
||||
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.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||
|
||||
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
|
||||
onNewIntent(intent);
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
var url = intent?.dataString;
|
||||
|
||||
var url = intent.dataString;
|
||||
if(url == 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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.matchesDomain
|
||||
import com.futo.platformplayer.others.LoginWebViewClient
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -74,9 +75,26 @@ class LoginActivity : AppCompatActivity() {
|
||||
finish();
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
@@ -86,6 +104,35 @@ class LoginActivity : AppCompatActivity() {
|
||||
//TODO: Find most reliable way to wait for page js to finish
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
|
||||
/*
|
||||
var specifiedScale = false;
|
||||
var specifiedDesktop = false;
|
||||
if(uiMods.size > 0 && url != null) {
|
||||
synchronized(uiMods) {
|
||||
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||
if(uimod != null) {
|
||||
if(uimod.scale != null) {
|
||||
currentScale =(uimod.scale * 100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
specifiedScale = true;
|
||||
}
|
||||
if(uimod.desktop != null && uimod.desktop) {
|
||||
_webView.settings.useWideViewPort = true;
|
||||
specifiedDesktop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!specifiedScale && currentScale != 100) {
|
||||
currentScale = (100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
}
|
||||
if(!specifiedDesktop && currentDesktop) {
|
||||
_webView.settings.useWideViewPort = false;
|
||||
currentDesktop = false;
|
||||
}
|
||||
*/
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -36,9 +35,11 @@ import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
@@ -51,17 +52,29 @@ import com.futo.platformplayer.fragment.mainactivity.main.CommentsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ContentSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.CreatorSearchResultsFragment
|
||||
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.HistoryFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ImportPlaylistsFragment
|
||||
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.PlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistSearchResultsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PostDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.RemotePlaylistFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ShortsFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SourcesFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionGroupFragment
|
||||
@@ -74,6 +87,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.WebDetailFragment
|
||||
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.ImportTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
|
||||
@@ -145,6 +159,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragTopBarNavigation: NavigationTopBarFragment;
|
||||
lateinit var _fragTopBarImport: ImportTopBarFragment;
|
||||
lateinit var _fragTopBarAdd: AddTopBarFragment;
|
||||
lateinit var _fragTopBarFiles: FilesTopBarFragment;
|
||||
|
||||
//Frags BotBar
|
||||
lateinit var _fragBotBarMenu: MenuBottomBarFragment;
|
||||
@@ -169,6 +184,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragMainRemotePlaylist: RemotePlaylistFragment;
|
||||
lateinit var _fragWatchlist: WatchLaterFragment;
|
||||
lateinit var _fragHistory: HistoryFragment;
|
||||
lateinit var _fragShorts: ShortsFragment;
|
||||
lateinit var _fragSourceDetail: SourceDetailFragment;
|
||||
lateinit var _fragDownloads: DownloadsFragment;
|
||||
lateinit var _fragImportSubscriptions: ImportSubscriptionsFragment;
|
||||
@@ -176,6 +192,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
lateinit var _fragBuy: BuyFragment;
|
||||
lateinit var _fragSubGroup: SubscriptionGroupFragment;
|
||||
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 _fragSettings: SettingsFragment;
|
||||
lateinit var _fragDeveloper: DeveloperFragment;
|
||||
lateinit var _fragLogin: LoginFragment;
|
||||
|
||||
lateinit var _fragBrowser: BrowserFragment;
|
||||
|
||||
@@ -196,6 +223,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
private var _privateModeEnabled = false
|
||||
private var _pictureInPictureEnabled = false
|
||||
private var _isFullscreen = false
|
||||
private lateinit var _rootInsetsController: RootInsetsController
|
||||
|
||||
private val _urlQrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
@@ -216,6 +244,19 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _notifPermission = "android.permission.POST_NOTIFICATIONS";
|
||||
private val _notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
if (isGranted)
|
||||
UIDialogs.toast(this, "Notification permission granted");
|
||||
else
|
||||
UIDialogs.toast(this, "Notification permission denied");
|
||||
};
|
||||
|
||||
|
||||
|
||||
fun requestNotificationPermissions() {
|
||||
_notificationPermissionLauncher?.launch(_notifPermission);
|
||||
}
|
||||
|
||||
val mainId = UUID.randomUUID().toString().substring(0, 5)
|
||||
|
||||
@@ -271,6 +312,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
@UnstableApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Logger.w(TAG, "MainActivity Starting [$mainId]");
|
||||
|
||||
StateApp.instance.setGlobalContext(this, lifecycleScope, mainId);
|
||||
StateApp.instance.mainAppStarting(this);
|
||||
|
||||
@@ -281,9 +323,6 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
setContentView(R.layout.activity_main);
|
||||
setNavigationBarColorAndIcons();
|
||||
if (Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
@@ -298,6 +337,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
FragmentedStorage.get<Settings>();
|
||||
|
||||
rootView = findViewById(R.id.rootView);
|
||||
_rootInsetsController = RootInsetsController.attach(this, rootView)
|
||||
_rootInsetsController.setLightSystemBarAppearance(lightStatus = false, lightNav = false)
|
||||
|
||||
_fragContainerTopBar = findViewById(R.id.fragment_top_bar);
|
||||
_fragContainerMain = findViewById(R.id.fragment_main);
|
||||
_fragContainerBotBar = findViewById(R.id.fragment_bottom_bar);
|
||||
@@ -314,6 +356,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
|
||||
_fragTopBarImport = ImportTopBarFragment.newInstance();
|
||||
_fragTopBarAdd = AddTopBarFragment.newInstance();
|
||||
_fragTopBarFiles = FilesTopBarFragment.newInstance();
|
||||
|
||||
//BotBars
|
||||
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
|
||||
@@ -338,6 +381,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragShorts = ShortsFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
_fragDownloads = DownloadsFragment();
|
||||
_fragImportSubscriptions = ImportSubscriptionsFragment.newInstance();
|
||||
@@ -345,6 +389,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragBuy = BuyFragment.newInstance();
|
||||
_fragSubGroup = SubscriptionGroupFragment.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();
|
||||
_fragSettings = SettingsFragment.newInstance();
|
||||
_fragDeveloper = DeveloperFragment.newInstance();
|
||||
_fragLogin = LoginFragment.newInstance();
|
||||
|
||||
_fragBrowser = BrowserFragment.newInstance();
|
||||
|
||||
@@ -407,6 +462,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "onFullscreenChanged ${it}");
|
||||
_isFullscreen = it
|
||||
updatePrivateModeVisibility()
|
||||
if (it) {
|
||||
_rootInsetsController.enterFullscreen(allowCutoutShortEdges = Settings.instance.playback.allowVideoToGoUnderCutout)
|
||||
} else {
|
||||
_rootInsetsController.exitFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
_fragVideoDetail.onMinimize.subscribe {
|
||||
@@ -471,6 +531,16 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_fragImportSubscriptions.topBar = _fragTopBarImport;
|
||||
_fragImportPlaylists.topBar = _fragTopBarImport;
|
||||
_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;
|
||||
|
||||
_fragBrowser.topBar = _fragTopBarNavigation;
|
||||
|
||||
@@ -635,6 +705,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
private var _qrCodeLoadingDialog: AlertDialog? = null
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
_rootInsetsController.onConfigurationChanged()
|
||||
}
|
||||
|
||||
fun showUrlQrCodeScanner() {
|
||||
try {
|
||||
_qrCodeLoadingDialog = UIDialogs.showDialog(this, R.drawable.ic_loader_animated, true,
|
||||
@@ -693,17 +768,13 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleIntent(intent);
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if (intent == null)
|
||||
return;
|
||||
private fun handleIntent(intent: Intent) {
|
||||
Logger.i(TAG, "handleIntent started by " + intent.action);
|
||||
|
||||
|
||||
var targetData: String? = null;
|
||||
|
||||
when (intent.action) {
|
||||
@@ -765,7 +836,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (targetData != null) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
handleUrlAll(targetData, intent)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
@@ -776,8 +847,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleUrlAll(url: String) {
|
||||
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
|
||||
val uri = Uri.parse(url)
|
||||
val intent = openIntent ?: this.intent;
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
if (url.startsWith("grayjay://license/")) {
|
||||
@@ -804,11 +876,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
if (!handleContent(url, intent.type)) {
|
||||
if (!handleContent(url, intent?.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -929,6 +1001,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
|
||||
val mediaItem = LocalVideoDetails.fromContent(file, mime);
|
||||
navigateWhenReady(_fragVideoDetail, mediaItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1043,7 +1121,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "handleFCast");
|
||||
|
||||
try {
|
||||
StateCasting.instance.handleUrl(this, url)
|
||||
StateCasting.instance.handleUrl(url)
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
||||
@@ -1126,6 +1204,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
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
@@ -1238,6 +1321,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
VideoDetailFragment::class -> _fragVideoDetail as T;
|
||||
MenuBottomBarFragment::class -> _fragBotBarMenu as T;
|
||||
GeneralTopBarFragment::class -> _fragTopBarGeneral as T;
|
||||
FilesTopBarFragment::class -> _fragTopBarFiles as T;
|
||||
SearchTopBarFragment::class -> _fragTopBarSearch as T;
|
||||
CreatorsFragment::class -> _fragMainSubscriptions as T;
|
||||
CommentsFragment::class -> _fragMainComments as T;
|
||||
@@ -1253,6 +1337,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
ShortsFragment::class -> _fragShorts as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
DownloadsFragment::class -> _fragDownloads as T;
|
||||
ImportSubscriptionsFragment::class -> _fragImportSubscriptions as T;
|
||||
@@ -1261,6 +1346,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
BuyFragment::class -> _fragBuy as T;
|
||||
SubscriptionGroupFragment::class -> _fragSubGroup 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;
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1285,6 +1381,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 requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
||||
|
||||
@@ -13,15 +13,18 @@ import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.SignedEvent
|
||||
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.toBase64Url
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -41,11 +46,27 @@ import userpackage.Protocol.URLInfo
|
||||
class PolycentricBackupActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonShare: BigButton;
|
||||
private lateinit var _buttonCopy: BigButton;
|
||||
private lateinit var _buttonExportFile: BigButton;
|
||||
private lateinit var _imageQR: ImageView;
|
||||
private lateinit var _exportBundle: String;
|
||||
private lateinit var _textQR: TextView;
|
||||
private lateinit var _textQRHint: TextView;
|
||||
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?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
@@ -57,8 +78,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_buttonShare = findViewById(R.id.button_share)
|
||||
_buttonCopy = findViewById(R.id.button_copy)
|
||||
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||
_imageQR = findViewById(R.id.image_qr)
|
||||
_textQR = findViewById(R.id.text_qr)
|
||||
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||
_loader = findViewById(R.id.progress_loader)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
@@ -66,14 +89,23 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
|
||||
_imageQR.visibility = View.INVISIBLE
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_textQRHint.visibility = View.INVISIBLE
|
||||
_loader.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
_buttonExportFile.visibility = View.INVISIBLE
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||
_exportBundle = bundle
|
||||
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||
|
||||
try {
|
||||
val pair = withContext(Dispatchers.IO) {
|
||||
val bundle = createExportBundle()
|
||||
if (!isContentSuitableForQRCode(bundle)) {
|
||||
throw Exception("Data too big for QR code generation")
|
||||
}
|
||||
|
||||
val dimension = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||
).toInt()
|
||||
@@ -81,18 +113,35 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
Pair(bundle, qr)
|
||||
}
|
||||
|
||||
_exportBundle = pair.first
|
||||
_imageQR.setImageBitmap(pair.second)
|
||||
_imageQR.visibility = View.VISIBLE
|
||||
_textQR.visibility = View.VISIBLE
|
||||
_textQRHint.visibility = View.VISIBLE
|
||||
_buttonShare.visibility = View.VISIBLE
|
||||
_buttonCopy.visibility = View.VISIBLE
|
||||
|
||||
_imageQR.setOnClickListener {
|
||||
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||
startActivity(intent)
|
||||
}
|
||||
} 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
|
||||
_textQR.visibility = View.INVISIBLE
|
||||
_buttonShare.visibility = View.INVISIBLE
|
||||
_buttonCopy.visibility = View.INVISIBLE
|
||||
} finally {
|
||||
_loader.visibility = View.GONE
|
||||
}
|
||||
@@ -108,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||
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 {
|
||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||
return bitMatrixToBitmap(bitMatrix);
|
||||
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 {
|
||||
@@ -203,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
||||
.setBody(exportBundle.toByteString())
|
||||
.build();
|
||||
|
||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
||||
val data = urlInfo.toByteArray()
|
||||
return "polycentric://" + data.toBase64Url()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+133
-61
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
||||
import userpackage.Protocol.ExportBundle
|
||||
|
||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _buttonScanProfile: LinearLayout;
|
||||
private lateinit var _buttonImportProfile: LinearLayout;
|
||||
private lateinit var _editProfile: EditText;
|
||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
||||
private lateinit var _buttonHelp: ImageButton
|
||||
private lateinit var _buttonScanProfile: LinearLayout
|
||||
private lateinit var _buttonImportFile: LinearLayout
|
||||
private lateinit var _buttonImportProfile: LinearLayout
|
||||
private lateinit var _editProfile: EditText
|
||||
private lateinit var _loaderOverlay: LoaderOverlay
|
||||
|
||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
if (it.contents != null) {
|
||||
val scannedUrl = it.contents
|
||||
import(scannedUrl)
|
||||
private val _qrCodeResultLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val scanResult =
|
||||
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||
scanResult?.let {
|
||||
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?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_polycentric_import_profile);
|
||||
setNavigationBarColorAndIcons();
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_polycentric_import_profile)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_buttonHelp = findViewById(R.id.button_help);
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
_editProfile = findViewById(R.id.edit_profile);
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish();
|
||||
};
|
||||
_buttonHelp = findViewById(R.id.button_help)
|
||||
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||
_editProfile = findViewById(R.id.edit_profile)
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||
|
||||
_buttonHelp.setOnClickListener {
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||
};
|
||||
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||
}
|
||||
|
||||
_buttonScanProfile.setOnClickListener {
|
||||
val integrator = IntentIntegrator(this)
|
||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||
integrator.setOrientationLocked(true);
|
||||
integrator.setOrientationLocked(true)
|
||||
integrator.setCameraId(0)
|
||||
integrator.setBeepEnabled(false)
|
||||
integrator.setBarcodeImageEnabled(true)
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||
};
|
||||
}
|
||||
|
||||
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||
|
||||
_buttonImportProfile.setOnClickListener {
|
||||
if (_editProfile.text.isEmpty()) {
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
||||
return@setOnClickListener;
|
||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
import(_editProfile.text.toString());
|
||||
};
|
||||
import(_editProfile.text.toString())
|
||||
}
|
||||
|
||||
val url = intent.getStringExtra("url");
|
||||
val url = intent.getStringExtra("url")
|
||||
if (url != null) {
|
||||
import(url);
|
||||
import(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun import(url: String) {
|
||||
if (!url.startsWith("polycentric://")) {
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
||||
return;
|
||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||
return
|
||||
}
|
||||
|
||||
_loaderOverlay.show()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
||||
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||
|
||||
if (urlInfo.urlType != 3L) {
|
||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||
}
|
||||
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||
if (existingProcessSecret != null) {
|
||||
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());
|
||||
Store.instance.addProcessSecret(processSecret);
|
||||
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||
Store.instance.addProcessSecret(processSecret)
|
||||
|
||||
try {
|
||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||
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) {
|
||||
try {
|
||||
val se = SignedEvent.fromProto(e);
|
||||
Store.instance.putSignedEvent(se);
|
||||
val se = SignedEvent.fromProto(e)
|
||||
Store.instance.putSignedEvent(se)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Ignored invalid event", e);
|
||||
Logger.w(TAG, "Ignored invalid event", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER);
|
||||
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||
withContext(Dispatchers.Main) {
|
||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
||||
finish();
|
||||
startActivity(
|
||||
Intent(
|
||||
this@PolycentricImportProfileActivity,
|
||||
PolycentricProfileActivity::class.java
|
||||
)
|
||||
)
|
||||
finish()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to import profile", e);
|
||||
Logger.w(TAG, "Failed to import profile", e)
|
||||
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 {
|
||||
withContext(Dispatchers.Main) {
|
||||
_loaderOverlay.hide();
|
||||
}
|
||||
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 _editName: EditText;
|
||||
private lateinit var _buttonExport: BigButton;
|
||||
private lateinit var _buttonOpenHarborProfile: BigButton;
|
||||
private lateinit var _buttonModeration: BigButton;
|
||||
private lateinit var _buttonLogout: BigButton;
|
||||
private lateinit var _buttonDelete: BigButton;
|
||||
private lateinit var _username: String;
|
||||
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||
_editName = findViewById(R.id.edit_profile_name);
|
||||
_buttonExport = findViewById(R.id.button_export);
|
||||
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
|
||||
_buttonModeration = findViewById(R.id.button_moderation);
|
||||
_buttonLogout = findViewById(R.id.button_logout);
|
||||
_buttonDelete = findViewById(R.id.button_delete);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonOpenHarborProfile.onClick.subscribe {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
processHandle?.let {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
|
||||
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
|
||||
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
}
|
||||
}
|
||||
_buttonModeration.onClick.subscribe {
|
||||
startActivity(Intent(this, PolycentricModerationActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonLogout.onClick.subscribe {
|
||||
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) {
|
||||
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) {
|
||||
if (complete != null) {
|
||||
if (complete) {
|
||||
|
||||
+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.HttpHeaders
|
||||
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.parsers.HttpResponseParser
|
||||
import com.futo.platformplayer.readLine
|
||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
private var _injectReferer = false;
|
||||
|
||||
private val _client = ManagedHttpClient();
|
||||
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||
|
||||
override fun handle(context: HttpContext) {
|
||||
if (useTcp) {
|
||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
||||
for (injectHeader in _injectRequestHeader)
|
||||
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)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
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"));
|
||||
|
||||
val resp = when (useMethod) {
|
||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
||||
"GET" -> _client.get(url, proxyHeaders);
|
||||
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||
"HEAD" -> _client.head(url, proxyHeaders)
|
||||
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||
};
|
||||
|
||||
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)
|
||||
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)
|
||||
proxyHeaders.put("Host", parsed.host!!);
|
||||
if(_injectReferer)
|
||||
proxyHeaders.put("Referer", targetUrl);
|
||||
proxyHeaders.put("Referer", url);
|
||||
|
||||
val useMethod = if (method == "inherit") context.method else method;
|
||||
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");
|
||||
return this;
|
||||
}
|
||||
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||
_requestModifier = modifier;
|
||||
return this;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HttpProxyHandler"
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
|
||||
@@ -36,6 +37,11 @@ interface IPlatformClient {
|
||||
*/
|
||||
fun getHome(): IPager<IPlatformContent>
|
||||
|
||||
/**
|
||||
* Gets the shorts feed
|
||||
*/
|
||||
fun getShorts(): IPager<IPlatformVideo>
|
||||
|
||||
//Search
|
||||
/**
|
||||
* Gets search suggestion for the provided query string
|
||||
@@ -176,6 +182,10 @@ interface IPlatformClient {
|
||||
* Retrieves the subscriptions of the currently logged in user
|
||||
*/
|
||||
fun getUserSubscriptions(): Array<String>;
|
||||
/**
|
||||
* Retrieves the history of the currently logged in user
|
||||
*/
|
||||
fun getUserHistory(): IPager<IPlatformContent>;
|
||||
|
||||
|
||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -26,12 +27,17 @@ class LiveChatManager {
|
||||
private val _emojiCache: EmojiCache = EmojiCache();
|
||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||
|
||||
private var _position: Long = 0;
|
||||
private var _eventsPosition: Long = 0;
|
||||
|
||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||
|
||||
private var _startCounter = 0;
|
||||
|
||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||
|
||||
val isVOD get() = _pager is JSVODEventPager;
|
||||
|
||||
var viewCount: Long = 0
|
||||
private set;
|
||||
|
||||
@@ -39,8 +45,24 @@ class LiveChatManager {
|
||||
_scope = scope;
|
||||
_pager = pager;
|
||||
viewCount = initialViewCount;
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
handleEvents(pager.getResults());
|
||||
if(pager is JSVODEventPager)
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
else
|
||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||
|
||||
if(pager is JSVODEventPager) {
|
||||
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||
//TODO: Remove this once dripfeed is done properly
|
||||
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = _eventsPosition + 1500;
|
||||
}
|
||||
else
|
||||
handleEvents(pager.getResults());
|
||||
}
|
||||
|
||||
fun start() {
|
||||
@@ -52,6 +74,10 @@ class LiveChatManager {
|
||||
_startCounter++;
|
||||
}
|
||||
|
||||
fun setVideoPosition(ms: Long) {
|
||||
_position = ms;
|
||||
}
|
||||
|
||||
fun getHistory(): List<IPlatformLiveEvent> {
|
||||
synchronized(_history) {
|
||||
return _history.toList();
|
||||
@@ -85,13 +111,34 @@ class LiveChatManager {
|
||||
try {
|
||||
while(_startCounter == counter) {
|
||||
var nextInterval = 1000L;
|
||||
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||
delay(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if(_pager == null || !_pager.hasMorePages())
|
||||
return@launch;
|
||||
_pager.nextPage();
|
||||
val newEvents = _pager.getResults();
|
||||
val newEvents = if(_pager is JSVODEventPager) {
|
||||
val requestPosition = _position;
|
||||
_pager.nextPage(requestPosition.toInt());
|
||||
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||
if(replayResults.size > 0) {
|
||||
_eventsPosition = replayResults.maxOf { it.time };
|
||||
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||
}
|
||||
else
|
||||
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
replayResults;
|
||||
}
|
||||
else {
|
||||
_pager.nextPage();
|
||||
_pager.getResults();
|
||||
}
|
||||
if(_pager is JSLiveEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
else if(_pager is JSVODEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
|
||||
if(newEvents.size > 0)
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
|
||||
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
|
||||
val hasGetContentChapters: Boolean = false,
|
||||
val hasPeekChannelContents: Boolean = false,
|
||||
val hasGetChannelPlaylists: Boolean = false,
|
||||
val hasGetContentRecommendations: Boolean = false
|
||||
val hasGetContentRecommendations: Boolean = false,
|
||||
val hasGetUserHistory: Boolean = false
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -54,14 +54,16 @@ interface IPlatformChannelContent : IPlatformContent {
|
||||
val subscribers: Long?
|
||||
}
|
||||
|
||||
open class JSChannelContent : JSContent, IPlatformChannelContent {
|
||||
override val contentType: ContentType get() = ContentType.CHANNEL
|
||||
override val thumbnail: String?
|
||||
override val subscribers: Long?
|
||||
open class JSChannelContent(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformChannelContent {
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Channel";
|
||||
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
|
||||
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
|
||||
}
|
||||
}
|
||||
final override val contentType: ContentType = ContentType.CHANNEL
|
||||
|
||||
override val thumbnail: String? =
|
||||
_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 java.time.OffsetDateTime
|
||||
|
||||
open class PlatformComment : IPlatformComment {
|
||||
override val contextUrl: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val message: String;
|
||||
override val rating: IRating;
|
||||
override val date: OffsetDateTime;
|
||||
open class PlatformComment(
|
||||
override val contextUrl: String,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val message: String,
|
||||
override val rating: IRating,
|
||||
override val date: OffsetDateTime,
|
||||
override val replyCount: Int? = null
|
||||
) : IPlatformComment {
|
||||
|
||||
override val replyCount: Int?;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
|
||||
NoCommentsPager()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.futo.platformplayer.getOrThrow
|
||||
|
||||
interface IPlatformLiveEvent {
|
||||
val type : LiveEventType;
|
||||
var time: Long;
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -18,12 +18,15 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
val colorName: String?;
|
||||
val badges: List<String>;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||
this.name = name;
|
||||
this.message = message;
|
||||
this.thumbnail = thumbnail;
|
||||
this.colorName = colorName;
|
||||
this.badges = badges ?: listOf();
|
||||
this.time = time;
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -39,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
obj.getOrThrow(config, "name", contextName),
|
||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||
obj.getOrThrow(config, "message", contextName),
|
||||
colorName, badges);
|
||||
colorName, badges,
|
||||
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
||||
|
||||
var expire: Int = 6000;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
|
||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||
this.name = name;
|
||||
|
||||
@@ -10,6 +10,8 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
||||
|
||||
val emojis: HashMap<String, String>;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(emojis: HashMap<String, String>) {
|
||||
this.emojis = emojis;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class LiveEventRaid: IPlatformLiveEvent {
|
||||
val targetUrl: String;
|
||||
val isOutgoing: Boolean;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||
this.targetName = name;
|
||||
this.targetUrl = url;
|
||||
|
||||
@@ -10,6 +10,8 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
||||
|
||||
val viewCount: Int;
|
||||
|
||||
override var time: Long = -1;
|
||||
|
||||
constructor(viewCount: Int) {
|
||||
this.viewCount = viewCount;
|
||||
}
|
||||
|
||||
+17
-3
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
|
||||
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
||||
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
|
||||
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
override val audioSources: Array<IAudioSource>;
|
||||
|
||||
constructor(video: VideoLocal) {
|
||||
videoSources = video.videoSource.toTypedArray();
|
||||
audioSources = video.audioSource.toTypedArray();
|
||||
}
|
||||
constructor(audio: LocalAudioContentSource) {
|
||||
videoSources = arrayOf()
|
||||
audioSources = arrayOf(audio);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
|
||||
this.videoSources = videoSources;
|
||||
this.audioSources = audioSources;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -14,7 +14,8 @@ class AudioUrlSource(
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false
|
||||
override var original: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource(
|
||||
override val format: String,
|
||||
) : ISubtitleSource {
|
||||
override val hasFetch: Boolean = false
|
||||
override val language: String? = null
|
||||
|
||||
override fun getSubtitles(): String? {
|
||||
return null
|
||||
|
||||
+4
-1
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
override val name: String;
|
||||
override val url: String?;
|
||||
override val format: String?;
|
||||
override val language: String?
|
||||
override val hasFetch: Boolean get() = false;
|
||||
|
||||
val filePath: String;
|
||||
|
||||
constructor(name: String, format: String?, filePath: String) {
|
||||
constructor(name: String, language: String?, format: String?, filePath: String) {
|
||||
this.name = name;
|
||||
this.format = format;
|
||||
this.language = language
|
||||
this.filePath = filePath;
|
||||
this.url = Uri.fromFile(File(filePath)).toString();
|
||||
}
|
||||
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
|
||||
return LocalSubtitleSource(
|
||||
source.name,
|
||||
source.language,
|
||||
source.format,
|
||||
path
|
||||
);
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
@kotlinx.serialization.Serializable
|
||||
class SubtitleRawSource(
|
||||
override val name: String,
|
||||
override val language: String?,
|
||||
override val format: String?,
|
||||
val _subtitles: String,
|
||||
override val url: String? = null,
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ open class VideoUrlSource(
|
||||
override val codec : String = "",
|
||||
override val bitrate : Int? = 0,
|
||||
|
||||
override var priority: Boolean = false
|
||||
override var priority: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ interface ISubtitleSource {
|
||||
val url: String?;
|
||||
val format: String?;
|
||||
val hasFetch: Boolean;
|
||||
val language: String?
|
||||
|
||||
fun getSubtitles(): String?;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
/**
|
||||
* A search result representing a video (overview data)
|
||||
@@ -12,6 +13,9 @@ interface IPlatformVideo : IPlatformContent {
|
||||
val duration: Long;
|
||||
val viewCount: Long;
|
||||
|
||||
val playbackTime: Long;
|
||||
val playbackDate: OffsetDateTime?;
|
||||
|
||||
val isLive : Boolean;
|
||||
|
||||
val isShort: Boolean;
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
|
||||
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.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class LocalVideoDetails(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val url: String,
|
||||
override val duration: Long,
|
||||
|
||||
val mimeType: String? = null,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
override val hls: IHLSManifestSource? get() = null;
|
||||
override val live: IVideoSource? get() = null;
|
||||
|
||||
|
||||
override val shareUrl: String = ""
|
||||
override val viewCount: Long = -1
|
||||
override val rating: IRating = RatingLikes(0)
|
||||
override val description: String = "";
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||
override val isShort: Boolean = false
|
||||
|
||||
fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
fun fromJson(str : String) : SerializedPlatformVideoDetails {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
companion object {
|
||||
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
if(filePath.startsWith("content://"))
|
||||
return fromContent(filePath, mimeType);
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
|
||||
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
|
||||
}
|
||||
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getFileNameFromContentUrl(url: String): String? {
|
||||
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
|
||||
cursor?.moveToFirst();
|
||||
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||
cursor?.close();
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -3,11 +3,10 @@ package com.futo.platformplayer.api.media.models.video
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnail
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
@@ -18,7 +17,7 @@ open class SerializedPlatformVideo(
|
||||
override val contentType: ContentType = ContentType.MEDIA,
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val thumbnails: Thumbnails = Thumbnails(),
|
||||
override val author: PlatformAuthorLink,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
@JsonNames("datetime", "dateTime")
|
||||
@@ -33,6 +32,10 @@ open class SerializedPlatformVideo(
|
||||
|
||||
override val isLive: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
|
||||
+4
-1
@@ -13,7 +13,6 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.*
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
@@ -43,6 +42,10 @@ open class SerializedPlatformVideoDetails(
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSCallDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocs
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter
|
||||
@@ -43,6 +44,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSPlaylistPager
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -101,7 +103,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
override val id: String get() = config.id;
|
||||
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();
|
||||
|
||||
private var _busyAction = "";
|
||||
@@ -124,6 +126,7 @@ open class JSClient : IPlatformClient {
|
||||
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
val enableInShorts get() = descriptor.appSettings.tabEnabled.enableShorts ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
@@ -144,7 +147,6 @@ open class JSClient : IPlatformClient {
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
|
||||
this._context = context;
|
||||
this.config = descriptor.config;
|
||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
_auth = descriptor.getAuth();
|
||||
@@ -175,7 +177,6 @@ open class JSClient : IPlatformClient {
|
||||
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
|
||||
this._context = context;
|
||||
this.config = descriptor.config;
|
||||
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
|
||||
this.descriptor = descriptor;
|
||||
_injectedSaveState = saveState;
|
||||
if(!withoutCredentials)
|
||||
@@ -269,7 +270,8 @@ open class JSClient : IPlatformClient {
|
||||
hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false,
|
||||
hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false,
|
||||
hasGetChannelPlaylists = plugin.executeBoolean("!!source.getChannelPlaylists") ?: false,
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false
|
||||
hasGetContentRecommendations = plugin.executeBoolean("!!source.getContentRecommendations") ?: false,
|
||||
hasGetUserHistory = plugin.executeBoolean("!!source.getUserHistory") ?: false
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -328,6 +330,13 @@ open class JSClient : IPlatformClient {
|
||||
plugin.executeTyped("source.getHome()"));
|
||||
}
|
||||
|
||||
@JSDocs(2, "source.getShorts()", "Gets the Shorts feed of the platform")
|
||||
override fun getShorts(): IPager<IPlatformVideo> = isBusyWith("getShorts") {
|
||||
ensureEnabled()
|
||||
return@isBusyWith JSVideoPager(config, this,
|
||||
plugin.executeTyped("source.getShorts()"))
|
||||
}
|
||||
|
||||
@JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query")
|
||||
@JSDocsParameter("query", "Query to complete suggestions for")
|
||||
override fun searchSuggestions(query: String): Array<String> = isBusyWith("searchSuggestions") {
|
||||
@@ -702,6 +711,13 @@ open class JSClient : IPlatformClient {
|
||||
.toTypedArray();
|
||||
}
|
||||
|
||||
@JSOptional
|
||||
@JSDocs(23, "source.getUserHistory()", "Gets the history of the current user")
|
||||
override fun getUserHistory(): IPager<IPlatformContent> {
|
||||
ensureEnabled();
|
||||
return JSContentPager(config, this, plugin.executeTyped("source.getUserHistory()"));
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
try {
|
||||
plugin.start();
|
||||
|
||||
+47
-3
@@ -1,6 +1,11 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.util.Dictionary
|
||||
|
||||
@Serializable
|
||||
class SourcePluginAuthConfig(
|
||||
val loginUrl: String,
|
||||
val completionUrl: String? = null,
|
||||
@@ -11,5 +16,44 @@ class SourcePluginAuthConfig(
|
||||
val userAgent: String? = null,
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null
|
||||
) { }
|
||||
val loginWarning: String? = null,
|
||||
val loginWarnings: List<Warning>? = null,
|
||||
val uiMods: List<UIMod>? = null
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
class Warning(
|
||||
val url: String,
|
||||
val text: String?,
|
||||
val details: String? = null,
|
||||
val once: Boolean? = true
|
||||
) {
|
||||
@Transient
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class UIMod(
|
||||
val url: String,
|
||||
val scale: Float?,
|
||||
val desktop: Boolean?
|
||||
) {
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -23,7 +23,7 @@ class SourcePluginConfig(
|
||||
//Script
|
||||
val repositoryUrl: String? = null,
|
||||
val scriptUrl: String = "",
|
||||
val version: Int = -1,
|
||||
var version: Int = -1,
|
||||
|
||||
val iconUrl: String? = null,
|
||||
var id: String = UUID.randomUUID().toString(),
|
||||
@@ -48,6 +48,7 @@ class SourcePluginConfig(
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var enableInShorts: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null,
|
||||
var developerSubmitUrl: String? = null,
|
||||
|
||||
+20
-2
@@ -5,10 +5,16 @@ import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.AnnouncementType
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -103,12 +109,22 @@ class SourcePluginDescriptor {
|
||||
@FormField(R.string.home, FieldForm.TOGGLE, R.string.show_content_in_home_tab, 1)
|
||||
var enableHome: Boolean? = null;
|
||||
|
||||
|
||||
@FormField(R.string.search, FieldForm.TOGGLE, R.string.show_content_in_search_results, 2)
|
||||
var enableSearch: Boolean? = null;
|
||||
|
||||
@FormField(R.string.shorts, FieldForm.TOGGLE, R.string.show_content_in_shorts_tab, 3)
|
||||
var enableShorts: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
@FormField(R.string.sync, "group", R.string.sync_desc, 3,"sync")
|
||||
var sync = Sync();
|
||||
@Serializable
|
||||
class Sync {
|
||||
@FormField(R.string.sync_history, FieldForm.TOGGLE, R.string.sync_history_desc, 1,"syncHistory")
|
||||
var enableHistorySync: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 4)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
@@ -143,6 +159,8 @@ class SourcePluginDescriptor {
|
||||
tabEnabled.enableHome = config.enableInHome
|
||||
if(tabEnabled.enableSearch == null)
|
||||
tabEnabled.enableSearch = config.enableInSearch
|
||||
if(tabEnabled.enableShorts == null)
|
||||
tabEnabled.enableShorts = config.enableInShorts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -254,6 +254,76 @@ class JSHttpClient : ManagedHttpClient {
|
||||
|
||||
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> {
|
||||
val cookieKey = cookie.substring(0, cookie.indexOf("="));
|
||||
|
||||
+16
-11
@@ -23,17 +23,22 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticle(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (obj.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
config,
|
||||
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
+24
-23
@@ -24,36 +24,37 @@ import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticleDetails(
|
||||
private val client: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
override val rating: IRating;
|
||||
private val _hasGetComments: Boolean = _content.has("getComments")
|
||||
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
override val rating: IRating =
|
||||
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
|
||||
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
|
||||
?: RatingLikes(0)
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
_content.getOrThrow(client.config, "summary", "PlatformArticle")
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
if(_content.has("thumbnails"))
|
||||
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (_content.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
client.config,
|
||||
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
thumbnails = null;
|
||||
null
|
||||
|
||||
|
||||
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
|
||||
?.map { fromV8Segment(client, it) }
|
||||
?.filterNotNull() ?: listOf());
|
||||
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
}
|
||||
override val segments: List<IJSArticleSegment> =
|
||||
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
|
||||
?.mapNotNull { fromV8Segment(client, it) }
|
||||
?: emptyList()
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
if(!_hasGetComments || _content.isClosed)
|
||||
|
||||
+34
-36
@@ -16,51 +16,49 @@ import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSContent : IPlatformContent, IPluginSourced {
|
||||
protected val _pluginConfig: SourcePluginConfig;
|
||||
protected val _content : V8ValueObject;
|
||||
open class JSContent(
|
||||
protected val _pluginConfig: SourcePluginConfig,
|
||||
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.has("getDetails")
|
||||
|
||||
override val id: PlatformID;
|
||||
override val name: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val datetime: OffsetDateTime?;
|
||||
override val id: PlatformID =
|
||||
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
|
||||
|
||||
override val url: String;
|
||||
override val shareUrl: String;
|
||||
override val name: 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) {
|
||||
_pluginConfig = config;
|
||||
_content = obj;
|
||||
private val _epoch: Long? =
|
||||
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
|
||||
|
||||
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));
|
||||
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
||||
override val url: String =
|
||||
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
|
||||
|
||||
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
|
||||
if(authorObj != null)
|
||||
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
|
||||
else
|
||||
author = PlatformAuthorLink.UNKNOWN;
|
||||
override val shareUrl: String =
|
||||
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
|
||||
|
||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||
datetime = null;
|
||||
else
|
||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
url = _content.getOrThrow(config, "url", contextName);
|
||||
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
|
||||
override val sourceConfig: SourcePluginConfig
|
||||
get() = _pluginConfig
|
||||
|
||||
_hasGetDetails = _content.has("getDetails");
|
||||
fun getUnderlyingObject(): V8ValueObject? = _content
|
||||
|
||||
companion object {
|
||||
private const val CTX = "PlatformContent"
|
||||
}
|
||||
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ abstract class JSPager<T> : IPager<T> {
|
||||
protected var pager: V8ValueObject;
|
||||
|
||||
private var _lastResults: List<T>? = null;
|
||||
private var _resultChanged: Boolean = true;
|
||||
private var _hasMorePages: Boolean = false;
|
||||
protected var _resultChanged: Boolean = true;
|
||||
protected var _hasMorePages: Boolean = false;
|
||||
//private var _morePagesWasFalse: Boolean = false;
|
||||
|
||||
val isAvailable get() = plugin.getUnderlyingPlugin()._runtime?.let { !it.isClosed && !it.isDead } ?: false;
|
||||
|
||||
+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.getOrDefault
|
||||
|
||||
open class JSPlaylist : JSContent, IPlatformPlaylist {
|
||||
override val contentType: ContentType get() = ContentType.PLAYLIST;
|
||||
override val thumbnail: String?;
|
||||
override val videoCount: Int;
|
||||
open class JSPlaylist(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformPlaylist {
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Playlist";
|
||||
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
|
||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
|
||||
}
|
||||
}
|
||||
override val contentType: ContentType = ContentType.PLAYLIST
|
||||
|
||||
override val thumbnail: String? =
|
||||
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
|
||||
|
||||
override val videoCount: Int =
|
||||
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
|
||||
}
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getSourcePlugin
|
||||
import com.futo.platformplayer.invokeV8
|
||||
@@ -22,6 +23,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
override val name: String;
|
||||
override val url: String?;
|
||||
override val format: String?;
|
||||
override val language: String?
|
||||
override val hasFetch: Boolean;
|
||||
|
||||
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
|
||||
@@ -29,6 +31,7 @@ class JSSubtitleSource : ISubtitleSource {
|
||||
|
||||
val context = "JSSubtitles";
|
||||
name = v8Value.getOrThrow(config, "name", context, false);
|
||||
language = v8Value.getOrDefault(config, "language", context, null);
|
||||
url = v8Value.getOrThrow(config, "url", context, true);
|
||||
format = v8Value.getOrThrow(config, "format", context, true);
|
||||
hasFetch = v8Value.has("getSubtitles");
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.V8Value
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPlatformLiveEventPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVODEventPager : JSPager<IPlatformLiveEvent>, IPlatformLiveEventPager {
|
||||
override var nextRequest: Int;
|
||||
|
||||
constructor(config: SourcePluginConfig, plugin: JSClient, pager: V8ValueObject) : super(config, plugin, pager) {
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
fun nextPage(ms: Int) = plugin.isBusyWith("JSLiveEventPager.nextPage") {
|
||||
warnIfMainThread("VODEventPager.nextPage");
|
||||
|
||||
val pluginV8 = plugin.getUnderlyingPlugin();
|
||||
pluginV8.busy {
|
||||
val newPager: V8Value = pluginV8.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage(...)") {
|
||||
pager.invokeV8<V8Value>("nextPage", ms);
|
||||
};
|
||||
if(newPager is V8ValueObject)
|
||||
pager = newPager;
|
||||
_hasMorePages = pager.getOrDefault(config, "hasMore", "Pager", false) ?: false;
|
||||
_resultChanged = true;
|
||||
}
|
||||
nextRequest = pager.getOrThrow(config, "nextRequest", "LiveEventPager");
|
||||
}
|
||||
|
||||
override fun nextPage() = nextPage(0);
|
||||
|
||||
override fun convertResult(obj: V8ValueObject): IPlatformLiveEvent {
|
||||
return IPlatformLiveEvent.fromV8(config, obj, "LiveEventPager");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
@@ -17,6 +21,10 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
final override val duration: Long;
|
||||
final override val viewCount: Long;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
final override val isLive: Boolean;
|
||||
final override val isShort: Boolean;
|
||||
|
||||
@@ -29,5 +37,11 @@ open class JSVideo : JSContent, IPlatformVideo, IPluginSourced {
|
||||
viewCount = _content.getOrThrow(config, "viewCount", contextName);
|
||||
isLive = _content.getOrThrow(config, "isLive", contextName);
|
||||
isShort = _content.getOrDefault(config, "isShort", contextName, false) ?: false;
|
||||
playbackTime = _content.getOrDefault<Long>(config, "playbackTime", contextName, -1)?.toLong() ?: -1;
|
||||
val playbackDateInt = _content.getOrDefault<Int>(config, "playbackDate", contextName, null)?.toLong();
|
||||
if(playbackDateInt == null || playbackDateInt == 0.toLong())
|
||||
playbackDate = null;
|
||||
else
|
||||
playbackDate = OffsetDateTime.of(LocalDateTime.ofEpochSecond(playbackDateInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -7,6 +7,7 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
@@ -26,12 +27,15 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
private val _plugin: JSClient;
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
private val _hasGetPlaybackTracker: Boolean;
|
||||
private val _hasGetVODEvents: Boolean;
|
||||
|
||||
//Details
|
||||
override val description : String;
|
||||
@@ -47,7 +51,6 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
|
||||
override val subtitles: List<ISubtitleSource>;
|
||||
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(plugin.config, obj) {
|
||||
val contextName = "VideoDetails";
|
||||
_plugin = plugin;
|
||||
@@ -72,6 +75,7 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetPlaybackTracker = _content.has("getPlaybackTracker");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
_hasGetVODEvents = _content.has("getVODEvents");
|
||||
}
|
||||
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? {
|
||||
@@ -138,4 +142,15 @@ class JSVideoDetails : JSVideo, IPlatformVideoDetails {
|
||||
return@busy JSCommentPager(_pluginConfig, client, commentPager);
|
||||
}
|
||||
}
|
||||
|
||||
fun hasVODEvents(): Boolean{
|
||||
return _hasGetVODEvents;
|
||||
}
|
||||
fun getVODEvents(url: String): IPager<IPlatformLiveEvent>? = _plugin.busy {
|
||||
if(!_hasGetVODEvents)
|
||||
return@busy null;
|
||||
|
||||
return@busy JSVODEventPager(_plugin.config, _plugin,
|
||||
_content.invokeV8<V8ValueObject>("getVODEvents", arrayOf<Any>()));
|
||||
}
|
||||
}
|
||||
+31
-30
@@ -8,43 +8,44 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
||||
override val name: String;
|
||||
override val bitrate : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
private val url : String;
|
||||
open class JSAudioUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
|
||||
|
||||
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) {
|
||||
val contextName = "AudioUrlSource";
|
||||
val config = plugin.config;
|
||||
private val url: String =
|
||||
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||
|
||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
||||
container = _obj.getOrThrow(config, "container", contextName);
|
||||
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);
|
||||
override val language: String =
|
||||
_obj.getOrThrow<String>(cfg, "language", ctx)
|
||||
|
||||
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;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
}
|
||||
override val name: String =
|
||||
_obj.getOrDefault<String>(cfg, "name", ctx, null)
|
||||
?: "$container $bitrate"
|
||||
|
||||
override fun getAudioUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override var priority: Boolean =
|
||||
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
|
||||
|
||||
override fun toString(): String {
|
||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
|
||||
}
|
||||
}
|
||||
override var original: Boolean =
|
||||
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)"
|
||||
}
|
||||
|
||||
+13
@@ -17,6 +17,7 @@ import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -57,12 +58,24 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
|
||||
hasGenerate = _obj.has("generate");
|
||||
}
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null;
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
if(!hasGenerate)
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
var result: V8Deferred<V8ValueString>? = null;
|
||||
|
||||
+51
-29
@@ -18,6 +18,7 @@ import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.invokeV8Async
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -30,39 +31,55 @@ interface IJSDashManifestRawSource {
|
||||
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
|
||||
fun generate(): String?;
|
||||
}
|
||||
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
override val container : String;
|
||||
override val name : String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val codec: String;
|
||||
override val bitrate: Int?;
|
||||
override val duration: Long;
|
||||
override val priority: Boolean;
|
||||
open class JSDashManifestRawSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
|
||||
|
||||
val url: String?;
|
||||
override var manifest: String?;
|
||||
private val ctx = "DashRawSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
override val hasGenerate: Boolean;
|
||||
val canMerge: Boolean;
|
||||
override val container: String =
|
||||
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
|
||||
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
override val name: String =
|
||||
_obj.getOrThrow<String>(cfg, "name", ctx)
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
|
||||
val contextName = "DashRawSource";
|
||||
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");
|
||||
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 = _obj.has("generate")
|
||||
|
||||
val canMerge: Boolean =
|
||||
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
|
||||
|
||||
override var streamMetaData: StreamMetaData? = null
|
||||
|
||||
private var _pregenerate: V8Deferred<String?>? = null
|
||||
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
|
||||
_pregenerate = generateAsync(scope);
|
||||
return _pregenerate;
|
||||
}
|
||||
|
||||
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
|
||||
@@ -70,6 +87,11 @@ open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSo
|
||||
return V8Deferred(CompletableDeferred(manifest));
|
||||
if(_obj.isClosed)
|
||||
throw IllegalStateException("Source object already closed");
|
||||
val pregenerated = _pregenerate;
|
||||
if(pregenerated != null) {
|
||||
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
|
||||
return pregenerated;
|
||||
}
|
||||
|
||||
val plugin = _plugin.getUnderlyingPlugin();
|
||||
|
||||
|
||||
+35
-30
@@ -5,42 +5,47 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
|
||||
override val width : Int;
|
||||
override val height : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
override val name : String;
|
||||
override val bitrate : Int;
|
||||
override val duration: Long;
|
||||
private val url : String;
|
||||
open class JSVideoUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
private val ctx = "JSVideoUrlSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
|
||||
val contextName = "JSVideoUrlSource";
|
||||
val config = plugin.config;
|
||||
override val width: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "width", ctx)
|
||||
|
||||
width = _obj.getOrThrow(config, "width", contextName);
|
||||
height = _obj.getOrThrow(config, "height", contextName);
|
||||
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);
|
||||
override val height: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "height", ctx)
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
override val container: String =
|
||||
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||
|
||||
override fun getVideoUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override val codec: String =
|
||||
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||
|
||||
override fun toString(): String {
|
||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||
}
|
||||
}
|
||||
override val name: String =
|
||||
_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 fun getVideoUrl(): String = url
|
||||
|
||||
override fun toString(): String =
|
||||
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||
}
|
||||
|
||||
+157
-2
@@ -1,5 +1,160 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local
|
||||
|
||||
class LocalClient {
|
||||
//TODO
|
||||
import android.content.ContentResolver
|
||||
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;
|
||||
}
|
||||
+5
-2
@@ -11,7 +11,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.DownloadedVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
@@ -19,7 +18,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
@@ -53,6 +52,10 @@ class LocalVideoDetails: IPlatformVideoDetails {
|
||||
override val isLive: Boolean = false;
|
||||
override val isShort: Boolean = false;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
constructor(file: File) {
|
||||
id = PlatformID("Local", file.path, "LOCAL")
|
||||
name = file.name;
|
||||
|
||||
+14
-4
@@ -1,13 +1,23 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
class LocalVideoMuxedSourceDescriptor(
|
||||
private val video: LocalVideoFileSource
|
||||
) : VideoMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
||||
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
|
||||
constructor(video: LocalVideoFileSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(video: LocalVideoContentSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>) {
|
||||
this.videoSources = videoSources;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN
|
||||
override val original: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioFileSource: IAudioSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN;
|
||||
override val original: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import java.io.File
|
||||
|
||||
class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+3
@@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
+5
-7
@@ -7,12 +7,12 @@ import java.util.stream.IntStream
|
||||
* A Content MultiPager that returns results based on a specified distribution
|
||||
* TODO: Merge all basic distribution pagers
|
||||
*/
|
||||
class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
|
||||
|
||||
private val dist : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val distConsumed : HashMap<IPager<IPlatformContent>, Float>;
|
||||
private val dist : HashMap<IPager<T>, Float>;
|
||||
private val distConsumed : HashMap<IPager<T>, Float>;
|
||||
|
||||
constructor(pagers : Map<IPager<IPlatformContent>, Float>) : super(pagers.keys.toMutableList()) {
|
||||
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
|
||||
val distTotal = pagers.values.sum();
|
||||
dist = HashMap();
|
||||
|
||||
@@ -25,7 +25,7 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun selectItemIndex(options: Array<SelectionOption<IPlatformContent>>): Int {
|
||||
override fun selectItemIndex(options: Array<SelectionOption<T>>): Int {
|
||||
if(options.size == 0)
|
||||
return -1;
|
||||
var bestIndex = 0;
|
||||
@@ -42,6 +42,4 @@ class MultiDistributionContentPager : MultiPager<IPlatformContent> {
|
||||
distConsumed[options[bestIndex].pager.getPager()] = bestConsumed;
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDevice {
|
||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
|
||||
@@ -2,147 +2,78 @@ 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 CastingDevice {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
abstract val isReady: Boolean
|
||||
abstract val usedRemoteAddress: InetAddress?
|
||||
abstract val localAddress: InetAddress?
|
||||
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
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
@Throws
|
||||
abstract fun resumePlayback()
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun pausePlayback()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun stopPlayback()
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun seekTo(timeSeconds: Double)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun changeVolume(timeSeconds: Double)
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun changeSpeed(speed: Double)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun connect()
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun disconnect()
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
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;
|
||||
@Throws
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
abstract fun ensureThreadStarted()
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class ChromecastCastingDevice : CastingDevice {
|
||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||
@@ -62,6 +62,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
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;
|
||||
@@ -305,6 +306,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
return;
|
||||
}
|
||||
|
||||
_autoLaunchEnabled = true
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
@@ -546,6 +548,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
|
||||
if (appId == "CC1AD845") {
|
||||
sessionIsRunning = true;
|
||||
_autoLaunchEnabled = false
|
||||
|
||||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
@@ -558,7 +561,6 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
_transportId = transportId;
|
||||
|
||||
requestMediaStatus();
|
||||
playVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,21 +570,22 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// 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()
|
||||
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 after $_launchRetries attempts; giving up.")
|
||||
Logger.e(TAG, "Player not found ($_launchRetries, _autoLaunchEnabled = $_autoLaunchEnabled); giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
@@ -599,6 +602,7 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
_autoLaunchEnabled = false
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
@@ -636,10 +640,16 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
stopVideo();
|
||||
}
|
||||
}
|
||||
|
||||
val needsLoad = statuses.length() == 0 || (statuses.getJSONObject(0).getString("playerState") == "IDLE")
|
||||
if (needsLoad && _contentId != null && _mediaSessionId == null) {
|
||||
Logger.i(TAG, "Receiver idle, sending initial LOAD")
|
||||
playVideo()
|
||||
}
|
||||
} else if (type == "CLOSE") {
|
||||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
stopCasting();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
@@ -676,6 +686,10 @@ class ChromecastCastingDevice : CastingDevice {
|
||||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_contentId = null
|
||||
_contentType = null
|
||||
_streamType = null
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
@@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -34,7 +32,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) {
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
class FCastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
@@ -348,7 +345,7 @@ class FCastCastingDevice : CastingDevice {
|
||||
headerBytesRead += read
|
||||
}
|
||||
|
||||
val size = ((buffer[3].toLong() shl 24) or (buffer[2].toLong() shl 16) or (buffer[1].toLong() shl 8) or buffer[0].toLong()).toInt();
|
||||
val size = ((buffer[3].toUByte().toLong() shl 24) or (buffer[2].toUByte().toLong() shl 16) or (buffer[1].toUByte().toLong() shl 8) or buffer[0].toUByte().toLong()).toInt();
|
||||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Packets larger than $size bytes are not supported.")
|
||||
break
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
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? {
|
||||
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 CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingExp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
if (foundInfo != null) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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.serialize
|
||||
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.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
@@ -47,10 +48,10 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
private val testPluginOrThrow: V8Plugin get() = _testPlugin ?: throw IllegalStateException("Attempted to use test plugin without plugin");
|
||||
private val _testPluginVariables: HashMap<String, V8RemoteObject> = hashMapOf();
|
||||
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject> {
|
||||
val remotes = mutableListOf<V8RemoteObject>();
|
||||
private inline fun <reified T> createRemoteObjectArray(objs: Iterable<T>): List<V8RemoteObject?> {
|
||||
val remotes = mutableListOf<V8RemoteObject?>();
|
||||
for(obj in objs)
|
||||
remotes.add(createRemoteObject(obj)!!);
|
||||
remotes.add(createRemoteObject(obj));
|
||||
return remotes;
|
||||
}
|
||||
private inline fun <reified T> createRemoteObject(obj: T): V8RemoteObject? {
|
||||
@@ -268,11 +269,15 @@ class DeveloperEndpoints(private val context: Context) {
|
||||
context.respondCode(403, "This plugin doesn't support auth");
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
_testPluginVariables.clear();
|
||||
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
|
||||
|
||||
};
|
||||
}; */
|
||||
context.respondCode(200, "Login started");
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -8,11 +8,13 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
|
||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
@@ -38,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
||||
R.array.exp_casting_device_type_array
|
||||
} else {
|
||||
R.array.casting_device_type_array
|
||||
}
|
||||
|
||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
_spinnerType.adapter = adapter;
|
||||
};
|
||||
@@ -101,12 +109,16 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_textError.visibility = View.GONE;
|
||||
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
||||
try {
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to add remembered device: $e")
|
||||
}
|
||||
performDismiss();
|
||||
};
|
||||
|
||||
_buttonTutorial.setOnClickListener {
|
||||
UIDialogs.showCastingTutorialDialog(context)
|
||||
UIDialogs.showCastingTutorialDialog(context, ownerActivity)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -130,7 +142,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
private fun performDismiss(shouldShowCastingDialog: Boolean = true) {
|
||||
if (shouldShowCastingDialog) {
|
||||
UIDialogs.showCastingDialog(context);
|
||||
UIDialogs.showCastingDialog(context, ownerActivity);
|
||||
}
|
||||
|
||||
dismiss();
|
||||
|
||||
@@ -53,7 +53,7 @@ class CastingHelpDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
findViewById<BigButton>(R.id.button_close).onClick.subscribe {
|
||||
dismiss()
|
||||
UIDialogs.showCastingAddDialog(context)
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
@@ -18,7 +17,6 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -83,7 +81,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonAdd.setOnClickListener {
|
||||
UIDialogs.showCastingAddDialog(context);
|
||||
UIDialogs.showCastingAddDialog(context, ownerActivity);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
val name = d.name
|
||||
if (name != null)
|
||||
if (name != null) {
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
updateUnifiedList()
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
@@ -139,9 +138,6 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_textNoDevicesFound.visibility = if (_devices.isEmpty()) View.VISIBLE else View.GONE;
|
||||
_recyclerDevices.visibility = if (_devices.isNotEmpty()) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
|
||||
@@ -12,12 +12,11 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonPlay = findViewById(R.id.button_play);
|
||||
_buttonPlay.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.resumeVideo()
|
||||
StateCasting.instance.resumeVideo()
|
||||
}
|
||||
|
||||
_buttonPause = findViewById(R.id.button_pause);
|
||||
_buttonPause.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.pauseVideo()
|
||||
StateCasting.instance.pauseVideo()
|
||||
}
|
||||
|
||||
_buttonStop = findViewById(R.id.button_stop);
|
||||
_buttonStop.setOnClickListener {
|
||||
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||
StateCasting.instance.activeDevice?.stopVideo()
|
||||
StateCasting.instance.stopVideo()
|
||||
}
|
||||
|
||||
_buttonNext = findViewById(R.id.button_next);
|
||||
@@ -90,7 +89,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonDisconnect.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
try {
|
||||
StateCasting.instance.stopVideo()
|
||||
StateCasting.instance.activeDevice?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Active device failed to disconnect: $e")
|
||||
}
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -99,12 +103,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
try {
|
||||
activeDevice.seekVideo(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
StateCasting.instance.videoSeekTo(value.toDouble())
|
||||
});
|
||||
|
||||
//TODO: Check if volume slider is properly hidden in all cases
|
||||
@@ -113,14 +112,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
if (activeDevice.canSetVolume) {
|
||||
try {
|
||||
activeDevice.changeVolume(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
}
|
||||
StateCasting.instance.changeVolume(value.toDouble())
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
@@ -172,15 +164,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private fun updateDevice() {
|
||||
val d = StateCasting.instance.activeDevice ?: return;
|
||||
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FastCast";
|
||||
when (d.protocolType) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
}
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
_textName.text = d.name;
|
||||
@@ -192,7 +194,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
||||
_sliderPosition.valueTo = dur
|
||||
|
||||
if (d.canSetVolume) {
|
||||
if (d.canSetVolume()) {
|
||||
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
||||
_layoutVolumeFixed.visibility = View.GONE;
|
||||
} else {
|
||||
@@ -214,8 +216,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
CastConnectionState.CONNECTED -> {
|
||||
enableControls(interactiveControls)
|
||||
}
|
||||
CastConnectionState.CONNECTING,
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
|
||||
disableControls(interactiveControls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
private lateinit var _buttonCancel1: Button;
|
||||
private lateinit var _buttonCancel2: Button;
|
||||
private lateinit var _buttonAlways: LinearLayout;
|
||||
private lateinit var _buttonUpdate: LinearLayout;
|
||||
|
||||
private lateinit var _buttonOk: LinearLayout;
|
||||
@@ -58,6 +59,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
private lateinit var _textProgres: TextView;
|
||||
private lateinit var _textError: TextView;
|
||||
private lateinit var _textResult: TextView;
|
||||
private lateinit var _textChangelogResult: TextView;
|
||||
|
||||
private lateinit var _uiChoiceTop: FrameLayout;
|
||||
private lateinit var _uiProgressTop: FrameLayout;
|
||||
@@ -89,6 +91,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
|
||||
_buttonCancel1 = findViewById(R.id.button_cancel_1);
|
||||
_buttonCancel2 = findViewById(R.id.button_cancel_2);
|
||||
_buttonAlways = findViewById(R.id.button_always);
|
||||
_buttonUpdate = findViewById(R.id.button_update);
|
||||
|
||||
_buttonOk = findViewById(R.id.button_ok);
|
||||
@@ -99,6 +102,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_textProgres = findViewById(R.id.text_progress);
|
||||
_textError = findViewById(R.id.text_error);
|
||||
_textResult = findViewById(R.id.text_result);
|
||||
_textChangelogResult = findViewById(R.id.text_changelog_result);
|
||||
|
||||
_uiChoiceTop = findViewById(R.id.dialog_ui_choice_top);
|
||||
_uiProgressTop = findViewById(R.id.dialog_ui_progress_top);
|
||||
@@ -119,17 +123,24 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val changelog = _newConfig.changelog!![changelogVersion]!!;
|
||||
if(changelog.size > 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog.map { " - " + it.trim() }.joinToString("\n");
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else if(changelog.size == 1) {
|
||||
_textChangelog.text = "Changelog (${_newConfig.version})\n" + changelog[0].trim();
|
||||
_textChangelogResult.text = _textChangelog.text;
|
||||
}
|
||||
else
|
||||
else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
} else
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
} else {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
_textChangelog.visibility = View.GONE;
|
||||
_textChangelogResult.visibility = View.GONE;
|
||||
Logger.e(TAG, "Invalid changelog? ", ex);
|
||||
}
|
||||
|
||||
@@ -145,6 +156,18 @@ class PluginUpdateDialog : AlertDialog {
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
_buttonAlways.setOnClickListener {
|
||||
if (_isUpdating)
|
||||
return@setOnClickListener;
|
||||
val plugin = StatePlugins.instance.getPlugin(_oldConfig.id);
|
||||
if(plugin != null) {
|
||||
plugin.appSettings.automaticUpdate = true;
|
||||
StatePlugins.instance.savePlugin(_oldConfig.id);
|
||||
UIDialogs.appToast("Automatic update enabled, can be disabled in plugin settings");
|
||||
}
|
||||
_isUpdating = true;
|
||||
update();
|
||||
};
|
||||
|
||||
Glide.with(_iconPlugin)
|
||||
.load(_oldConfig.absoluteIconUrl)
|
||||
@@ -158,7 +181,8 @@ class PluginUpdateDialog : AlertDialog {
|
||||
if (_isUpdating)
|
||||
return;
|
||||
_isUpdating = true;
|
||||
update();
|
||||
|
||||
update(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,7 +191,7 @@ class PluginUpdateDialog : AlertDialog {
|
||||
super.dismiss();
|
||||
}
|
||||
|
||||
private fun update() {
|
||||
private fun update(automatic: Boolean = false) {
|
||||
_uiChoiceTop.visibility = View.GONE;
|
||||
_uiRiskTop.visibility = View.GONE;
|
||||
_uiChoiceBot.visibility = View.GONE;
|
||||
@@ -187,9 +211,16 @@ class PluginUpdateDialog : AlertDialog {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
scope?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Loading current script file...");
|
||||
}
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
val script = StatePlugins.instance.getScript(_oldConfig.id) ?: "";
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_textProgres.setText("Requesting new script file...");
|
||||
}
|
||||
val newScript = client.get(_newConfig.absoluteScriptUrl)?.body?.string();
|
||||
if(newScript.isNullOrEmpty())
|
||||
throw IllegalStateException("No script found");
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.arthenica.ffmpegkit.StatisticsCallback
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
@@ -136,6 +137,8 @@ class VideoDownload {
|
||||
|
||||
var hasVideoRequestExecutor: Boolean = false;
|
||||
var hasAudioRequestExecutor: Boolean = false;
|
||||
var hasVideoRequestModifier: Boolean = false;
|
||||
var hasAudioRequestModifier: Boolean = false;
|
||||
|
||||
var progress: Double = 0.0;
|
||||
var isCancelled = false;
|
||||
@@ -203,8 +206,10 @@ class VideoDownload {
|
||||
this.prepareTime = OffsetDateTime.now();
|
||||
this.hasVideoRequestExecutor = videoSource is JSSource && videoSource.hasRequestExecutor;
|
||||
this.hasAudioRequestExecutor = audioSource is JSSource && audioSource.hasRequestExecutor;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.hasVideoRequestModifier = videoSource is JSSource && videoSource.hasRequestModifier;
|
||||
this.hasAudioRequestModifier = audioSource is JSSource && audioSource.hasRequestModifier;
|
||||
this.requiresLiveVideoSource = this.hasVideoRequestModifier || this.hasVideoRequestExecutor || (videoSource is JSDashManifestRawSource && videoSource.hasGenerate);
|
||||
this.requiresLiveAudioSource = this.hasAudioRequestModifier || this.hasAudioRequestExecutor || (audioSource is JSDashManifestRawAudioSource && audioSource.hasGenerate);
|
||||
this.targetVideoName = videoSource?.name;
|
||||
this.targetAudioName = audioSource?.name;
|
||||
this.targetPixelCount = if(videoSource != null) (videoSource.width * videoSource.height).toLong() else null;
|
||||
@@ -303,9 +308,10 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, source.url))
|
||||
videoSources.addAll(HLS.parseAndGetVideoSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -351,9 +357,10 @@ class VideoDownload {
|
||||
try {
|
||||
val playlistResponse = client.get(source.url)
|
||||
if (playlistResponse.isOk) {
|
||||
val resolvedPlaylistUrl = playlistResponse.url
|
||||
val playlistContent = playlistResponse.body?.string()
|
||||
if (playlistContent != null) {
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, source.url))
|
||||
audioSources.addAll(HLS.parseAndGetAudioSources(source, playlistContent, resolvedPlaylistUrl))
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
@@ -476,8 +483,8 @@ class VideoDownload {
|
||||
|
||||
if(actualVideoSource is IVideoUrlSource)
|
||||
videoFileSize = when (videoSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Video", client, if (actualVideoSource is JSSource) actualVideoSource else null, videoSource!!.getVideoUrl(), File(downloadDir, videoFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualVideoSource is JSDashManifestRawSource) {
|
||||
videoFileSize = downloadDashFileSource("Video", client, actualVideoSource, File(downloadDir, videoFileName!!), progressCallback);
|
||||
@@ -516,8 +523,8 @@ class VideoDownload {
|
||||
|
||||
if(actualAudioSource is IAudioUrlSource)
|
||||
audioFileSize = when (audioSource!!.container) {
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
"application/vnd.apple.mpegurl" -> downloadHlsSource(context, "Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
else -> downloadFileSource("Audio", client, if (actualAudioSource is JSSource) actualAudioSource else null, audioSource!!.getAudioUrl(), File(downloadDir, audioFileName!!), progressCallback)
|
||||
}
|
||||
else if(actualAudioSource is JSDashManifestRawAudioSource) {
|
||||
audioFileSize = downloadDashFileSource("Audio", client, actualAudioSource, File(downloadDir, audioFileName!!), progressCallback);
|
||||
@@ -578,110 +585,266 @@ class VideoDownload {
|
||||
return cipher.doFinal(encryptedSegment)
|
||||
}
|
||||
|
||||
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
private fun remuxWithFfmpegInPlace(inputFile: File): Boolean {
|
||||
val inputPath = inputFile.absolutePath
|
||||
if (!inputFile.exists()) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input does not exist: $inputPath")
|
||||
return false
|
||||
}
|
||||
|
||||
var downloadedTotalLength = 0L
|
||||
val parent = inputFile.parentFile
|
||||
if (parent == null) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: input has no parent: $inputPath")
|
||||
return false
|
||||
}
|
||||
|
||||
val segmentFiles = arrayListOf<File>()
|
||||
try {
|
||||
val response = client.get(hlsUrl)
|
||||
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
|
||||
val tmpFile = File(parent, inputFile.nameWithoutExtension + "_fixed." + inputFile.extension)
|
||||
val cmd = buildString {
|
||||
append("-y ")
|
||||
append("-i \"").append(inputFile.absolutePath).append("\" ")
|
||||
append("-c copy ")
|
||||
append("-movflags +faststart ")
|
||||
append("\"").append(tmpFile.absolutePath).append("\"")
|
||||
}
|
||||
|
||||
val vpContent = response.body?.string()
|
||||
?: throw Exception("Variant playlist content is empty")
|
||||
Logger.i(TAG, "FFmpeg remux command: $cmd")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
|
||||
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
|
||||
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
|
||||
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
|
||||
val session = FFmpegKit.execute(cmd)
|
||||
val returnCode = session.returnCode
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
val newLen = tmpFile.length()
|
||||
|
||||
if (!inputFile.delete()) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to delete original: ${inputFile.absolutePath}")
|
||||
}
|
||||
|
||||
if (!tmpFile.renameTo(inputFile)) {
|
||||
Logger.w(TAG, "remuxWithFfmpegInPlace: failed to move tmp: ${tmpFile.absolutePath}")
|
||||
} else {
|
||||
null
|
||||
Logger.i(TAG, "remuxWithFfmpegInPlace: success for $inputPath (size=$newLen bytes)")
|
||||
}
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index Sequential");
|
||||
val segmentFile = File(context.cacheDir, "segment-${UUID.randomUUID()}")
|
||||
val outputStream = segmentFile.outputStream()
|
||||
try {
|
||||
segmentFiles.add(segmentFile)
|
||||
|
||||
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
|
||||
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
|
||||
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
|
||||
}
|
||||
|
||||
downloadedTotalLength += segmentLength
|
||||
} finally {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
Logger.i(TAG, "Combining segments into $targetFile");
|
||||
combineSegments(context, segmentFiles, targetFile)
|
||||
|
||||
Logger.i(TAG, "${name} downloadSource Finished");
|
||||
return true
|
||||
} else {
|
||||
Logger.e(TAG, "FFmpeg remux failed for $inputPath. rc=$returnCode, logs=${session.allLogsAsString}")
|
||||
tmpFile.delete()
|
||||
return false
|
||||
}
|
||||
catch(ioex: IOException) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
if(ioex.message?.contains("ENOSPC") ?: false)
|
||||
throw Exception("Not enough space on device", ioex);
|
||||
else
|
||||
throw ioex;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
throw ex;
|
||||
}
|
||||
finally {
|
||||
for (segmentFile in segmentFiles) {
|
||||
segmentFile.delete()
|
||||
}
|
||||
}
|
||||
return downloadedTotalLength;
|
||||
}
|
||||
|
||||
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
private fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, source: JSSource?, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
var downloadedTotalLength = 0L
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier()
|
||||
else
|
||||
null
|
||||
|
||||
fun downloadBytes(url: String, rangeStart: Long? = null, rangeLength: Long? = null): ByteArray {
|
||||
val headers = mutableMapOf<String, String>()
|
||||
|
||||
if (rangeStart != null) {
|
||||
if (rangeLength != null && rangeLength > 0) {
|
||||
val end = rangeStart + rangeLength - 1
|
||||
headers["Range"] = "bytes=$rangeStart-$end"
|
||||
} else {
|
||||
headers["Range"] = "bytes=$rangeStart-"
|
||||
}
|
||||
}
|
||||
|
||||
val executorService = Executors.newSingleThreadExecutor()
|
||||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
"Command cancelled"
|
||||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
{ Logger.v(TAG, it.message) },
|
||||
statisticsCallback,
|
||||
executorService
|
||||
val modified = modifier?.modifyRequest(url, headers)
|
||||
val finalUrl = modified?.url ?: url
|
||||
val finalHeaders = modified?.headers?.toMutableMap() ?: headers
|
||||
|
||||
val resp = client.get(finalUrl, finalHeaders)
|
||||
if (!resp.isOk) {
|
||||
resp.body?.close()
|
||||
throw IllegalStateException("Failed to download HLS resource ($finalUrl): HTTP ${resp.code}")
|
||||
}
|
||||
|
||||
val body = resp.body ?: throw IllegalStateException("Failed to download HLS resource ($finalUrl): Empty body")
|
||||
val bytes = body.bytes()
|
||||
body.close()
|
||||
return bytes
|
||||
}
|
||||
|
||||
fun buildSequenceIv(sequenceNumber: Long): ByteArray {
|
||||
return ByteBuffer.allocate(16)
|
||||
.putLong(0L)
|
||||
.putLong(sequenceNumber)
|
||||
.array()
|
||||
}
|
||||
|
||||
try {
|
||||
val playlistHeaders = mutableMapOf<String, String>()
|
||||
val modifiedPlaylistReq = modifier?.modifyRequest(hlsUrl, playlistHeaders)
|
||||
val playlistResp = client.get(
|
||||
modifiedPlaylistReq?.url ?: hlsUrl,
|
||||
modifiedPlaylistReq?.headers?.toMutableMap() ?: playlistHeaders
|
||||
)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
session.cancel()
|
||||
check(playlistResp.isOk) { "Failed to get variant playlist: ${playlistResp.code}" }
|
||||
|
||||
val vpContent = playlistResp.body?.string()
|
||||
?: throw IllegalStateException("Variant playlist content is empty")
|
||||
|
||||
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
|
||||
val hlsDec = variantPlaylist.decryptionInfo
|
||||
val useDecryption = hlsDec != null && !hlsDec.method.equals("NONE", ignoreCase = true)
|
||||
var keyBytes: ByteArray? = null
|
||||
var staticIvBytes: ByteArray? = null
|
||||
|
||||
if (useDecryption) {
|
||||
if (!hlsDec.method.equals("AES-128", ignoreCase = true)) {
|
||||
throw UnsupportedOperationException("HLS decryption method '${hlsDec.method}' is not supported.")
|
||||
}
|
||||
|
||||
val keyUrl = hlsDec.keyUrl ?: throw IllegalStateException("Encrypted HLS playlist without key URI is not supported.")
|
||||
|
||||
keyBytes = downloadBytes(keyUrl)
|
||||
if (!hlsDec.iv.isNullOrEmpty()) {
|
||||
staticIvBytes = hlsDec.iv.hexStringToByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
val mediaSequence = variantPlaylist.mediaSequence ?: 0L
|
||||
val rangeOffsets = mutableMapOf<String, Long>()
|
||||
|
||||
targetFile.outputStream().use { outStr ->
|
||||
if (!variantPlaylist.mapUrl.isNullOrEmpty()) {
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Downloading HLS initialization map")
|
||||
|
||||
var mapRangeStart: Long? = null
|
||||
var mapRangeLength: Long? = null
|
||||
|
||||
if (variantPlaylist.mapBytesLength > 0) {
|
||||
mapRangeLength = variantPlaylist.mapBytesLength
|
||||
|
||||
val mapUrl = variantPlaylist.mapUrl!!
|
||||
if (variantPlaylist.mapBytesStart >= 0) {
|
||||
mapRangeStart = variantPlaylist.mapBytesStart
|
||||
rangeOffsets[mapUrl] =
|
||||
variantPlaylist.mapBytesStart + variantPlaylist.mapBytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[mapUrl] ?: 0L
|
||||
mapRangeStart = offset
|
||||
rangeOffsets[mapUrl] = offset + variantPlaylist.mapBytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var mapBytes = downloadBytes(variantPlaylist.mapUrl!!, mapRangeStart, mapRangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val iv = staticIvBytes
|
||||
?: throw UnsupportedOperationException("Encrypted EXT-X-MAP without explicit IV is not supported.")
|
||||
mapBytes = decryptSegment(mapBytes, kb, iv)
|
||||
}
|
||||
|
||||
if (mapBytes.size.toLong() > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS MAP segment too large to handle.")
|
||||
}
|
||||
|
||||
outStr.write(mapBytes)
|
||||
outStr.flush()
|
||||
downloadedTotalLength += mapBytes.size
|
||||
}
|
||||
|
||||
val totalSegments = variantPlaylist.segments.size
|
||||
var mediaSegmentIndex = 0
|
||||
|
||||
var bytesSinceLastSpeedUpdate = 0L
|
||||
var lastSpeedUpdateTime = System.currentTimeMillis()
|
||||
var lastSpeed = 0L
|
||||
|
||||
variantPlaylist.segments.forEachIndexed { index, segment ->
|
||||
if (segment !is HLS.MediaSegment) return@forEachIndexed
|
||||
if (isCancelled) throw CancellationException("Cancelled")
|
||||
|
||||
Logger.i(TAG, "Download '$name' segment $index sequential")
|
||||
|
||||
var rangeStart: Long? = null
|
||||
var rangeLength: Long? = null
|
||||
|
||||
if (segment.bytesLength > 0) {
|
||||
rangeLength = segment.bytesLength
|
||||
|
||||
val urlKey = segment.uri
|
||||
if (segment.bytesStart >= 0) {
|
||||
rangeStart = segment.bytesStart
|
||||
rangeOffsets[urlKey] = segment.bytesStart + segment.bytesLength
|
||||
} else {
|
||||
val offset = rangeOffsets[urlKey] ?: 0L
|
||||
rangeStart = offset
|
||||
rangeOffsets[urlKey] = offset + segment.bytesLength
|
||||
}
|
||||
}
|
||||
|
||||
var segmentBytes = downloadBytes(segment.uri, rangeStart, rangeLength)
|
||||
|
||||
if (useDecryption) {
|
||||
val kb = keyBytes ?: throw IllegalStateException("Decryption key bytes are missing.")
|
||||
val ivBytes = if (staticIvBytes != null) {
|
||||
staticIvBytes!!
|
||||
} else {
|
||||
val sequenceNumber = mediaSequence + mediaSegmentIndex
|
||||
buildSequenceIv(sequenceNumber)
|
||||
}
|
||||
|
||||
segmentBytes = decryptSegment(segmentBytes, kb, ivBytes)
|
||||
}
|
||||
|
||||
val segmentLength = segmentBytes.size.toLong()
|
||||
if (segmentLength > Int.MAX_VALUE) {
|
||||
throw IllegalStateException("HLS media segment too large to handle.")
|
||||
}
|
||||
|
||||
val avgLen = if (index == 0) {
|
||||
segmentLength
|
||||
} else {
|
||||
if (index > 0) downloadedTotalLength / index else segmentLength
|
||||
}
|
||||
val expectedTotal = avgLen * (totalSegments - 1) + segmentLength
|
||||
|
||||
outStr.write(segmentBytes)
|
||||
downloadedTotalLength += segmentLength
|
||||
|
||||
bytesSinceLastSpeedUpdate += segmentLength
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastSpeedUpdateTime
|
||||
if (elapsed >= 500 && bytesSinceLastSpeedUpdate > 0) {
|
||||
lastSpeed = (bytesSinceLastSpeedUpdate * 1000L / elapsed)
|
||||
bytesSinceLastSpeedUpdate = 0
|
||||
lastSpeedUpdateTime = now
|
||||
}
|
||||
|
||||
onProgress(expectedTotal, downloadedTotalLength, lastSpeed)
|
||||
mediaSegmentIndex++
|
||||
}
|
||||
}
|
||||
|
||||
remuxWithFfmpegInPlace(targetFile)
|
||||
|
||||
Logger.i(TAG, "Finished HLS Source for $name")
|
||||
} catch (ioex: IOException) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
if (ioex.message?.contains("ENOSPC") == true)
|
||||
throw Exception("Not enough space on device", ioex)
|
||||
else
|
||||
throw ioex
|
||||
} catch (ex: Throwable) {
|
||||
if (targetFile.exists())
|
||||
targetFile.delete()
|
||||
throw ex
|
||||
}
|
||||
|
||||
return downloadedTotalLength
|
||||
}
|
||||
|
||||
private fun downloadDashFileSource(name: String, client: ManagedHttpClient, source: IJSDashManifestRawSource, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
@@ -713,23 +876,30 @@ class VideoDownload {
|
||||
source.getRequestExecutor();
|
||||
else
|
||||
null;
|
||||
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
val speedTracker = SpeedTracker(1000);
|
||||
|
||||
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
|
||||
|
||||
var written = 0;
|
||||
var written: Long = 0;
|
||||
var indexCounter = 0;
|
||||
onProgress(foundCues.count().toLong(), 0, 0);
|
||||
for(cue in foundCues) {
|
||||
val t = cue.groupValues[1];
|
||||
val d = cue.groupValues[2];
|
||||
|
||||
|
||||
val url = foundTemplateUrl.replace("\$Number\$", (indexCounter).toString());
|
||||
val modified = modifier?.modifyRequest(url, mapOf());
|
||||
|
||||
val data = if(executor != null)
|
||||
executor.executeRequest("GET", url, null, mapOf());
|
||||
executor.executeRequest("GET", modified?.url ?: url, null, modified?.headers ?: mapOf());
|
||||
else {
|
||||
val resp = client.get(url, mutableMapOf());
|
||||
val resp = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: mutableMapOf());
|
||||
if(!resp.isOk)
|
||||
throw IllegalStateException("Dash request failed for index " + indexCounter.toString() + ", with code: " + resp.code.toString());
|
||||
resp.body!!.bytes()
|
||||
@@ -742,7 +912,7 @@ class VideoDownload {
|
||||
|
||||
indexCounter++;
|
||||
}
|
||||
sourceLength = written.toLong();
|
||||
sourceLength = written;
|
||||
|
||||
Logger.i(TAG, "$name downloadSource Finished");
|
||||
}
|
||||
@@ -764,7 +934,7 @@ class VideoDownload {
|
||||
}
|
||||
return sourceLength!!;
|
||||
}
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadFileSource(name: String, client: ManagedHttpClient, source: JSSource?, videoUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
||||
@@ -773,7 +943,12 @@ class VideoDownload {
|
||||
val sourceLength: Long?;
|
||||
val fileStream = FileOutputStream(targetFile);
|
||||
|
||||
try{
|
||||
val modifier = if (source is JSSource && source.hasRequestModifier)
|
||||
source.getRequestModifier();
|
||||
else
|
||||
null;
|
||||
|
||||
try {
|
||||
val head = client.tryHead(videoUrl);
|
||||
val relatedPlugin = (video?.url ?: videoDetails?.url)?.let { StatePlatform.instance.getContentClient(it) }?.let { if(it is JSClient) it else null };
|
||||
if(Settings.instance.downloads.byteRangeDownload && head?.containsKey("accept-ranges") == true && head.containsKey("content-length"))
|
||||
@@ -784,12 +959,12 @@ class VideoDownload {
|
||||
Logger.i(TAG, "Download $name ByteRange Parallel (${concurrency}): " + videoUrl);
|
||||
sourceLength = head["content-length"]!!.toLong();
|
||||
onProgress(sourceLength, 0, 0);
|
||||
downloadSource_Ranges(name, client, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
downloadSource_Ranges(name, client, modifier, fileStream, videoUrl, sourceLength, 1024*512, concurrency, onProgress);
|
||||
}
|
||||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, modifier, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
@@ -840,7 +1015,7 @@ class VideoDownload {
|
||||
}
|
||||
}
|
||||
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
private fun downloadSource_Sequential(client: ManagedHttpClient, modifier: IRequestModifier? = null, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -849,7 +1024,12 @@ class VideoDownload {
|
||||
|
||||
var lastSpeed: Long = 0;
|
||||
|
||||
val result = client.get(url);
|
||||
val result = if (modifier != null) {
|
||||
val modified = modifier.modifyRequest(url, mapOf())
|
||||
client.get(modified.url!!, modified.headers.toMutableMap())
|
||||
} else {
|
||||
client.get(url)
|
||||
}
|
||||
if (!result.isOk) {
|
||||
result.body?.close()
|
||||
throw IllegalStateException("Failed to download source. Web[${result.code}] Error");
|
||||
@@ -986,7 +1166,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0)
|
||||
return sourceLength
|
||||
}*/
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
private fun downloadSource_Ranges(name: String, client: ManagedHttpClient, modifier: IRequestModifier?, fileStream: FileOutputStream, url: String, sourceLength: Long, rangeSize: Int, concurrency: Int = 1, onProgress: (Long, Long, Long) -> Unit) {
|
||||
val progressRate: Int = 4096 * 5;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
@@ -1005,7 +1185,7 @@ class VideoDownload {
|
||||
|
||||
Logger.i(TAG, "Download ${name} Batch #${reqCount} [${concurrency}] (${lastSpeed.toHumanBytesSpeed()})");
|
||||
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, url, sourceLength, concurrency, totalRead,
|
||||
val byteRangeResults = requestByteRangeParallel(client, pool, modifier, url, sourceLength, concurrency, totalRead,
|
||||
rangeSize, 1024 * 64);
|
||||
|
||||
for(byteRange in byteRangeResults) {
|
||||
@@ -1036,7 +1216,7 @@ class VideoDownload {
|
||||
onProgress(sourceLength, totalRead, 0);
|
||||
}
|
||||
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
private fun requestByteRangeParallel(client: ManagedHttpClient, pool: ForkJoinPool, modifier: IRequestModifier?, url: String, totalLength: Long, concurrency: Int, rangePosition: Long, rangeSize: Int, rangeVariance: Int = -1): List<Triple<ByteArray, Long, Long>> {
|
||||
val tasks = mutableListOf<ForkJoinTask<Triple<ByteArray, Long, Long>>>();
|
||||
var readPosition = rangePosition;
|
||||
for(i in 0 until concurrency) {
|
||||
@@ -1050,21 +1230,25 @@ class VideoDownload {
|
||||
else readPosition + toRead;
|
||||
|
||||
tasks.add(pool.submit<Triple<ByteArray, Long, Long>> {
|
||||
return@submit requestByteRange(client, url, rangeStart, rangeEnd);
|
||||
return@submit requestByteRange(client, modifier, url, rangeStart, rangeEnd);
|
||||
});
|
||||
readPosition = rangeEnd + 1;
|
||||
}
|
||||
|
||||
return tasks.map { it.get() };
|
||||
}
|
||||
private fun requestByteRange(client: ManagedHttpClient, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
private fun requestByteRange(client: ManagedHttpClient, modifier: IRequestModifier?, url: String, rangeStart: Long, rangeEnd: Long): Triple<ByteArray, Long, Long> {
|
||||
var retryCount = 0
|
||||
var lastException: Throwable? = null
|
||||
var lastException: Throwable? = null;
|
||||
|
||||
val headers = mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}"));
|
||||
val modified = modifier?.modifyRequest(url, headers);
|
||||
|
||||
while (retryCount <= 3) {
|
||||
try {
|
||||
val toRead = rangeEnd - rangeStart;
|
||||
val req = client.get(url, mutableMapOf(Pair("Range", "bytes=${rangeStart}-${rangeEnd}")));
|
||||
|
||||
val req = client.get(modified?.url ?: url, modified?.headers?.toMutableMap() ?: headers);
|
||||
if (!req.isOk) {
|
||||
val bodyString = req.body?.string()
|
||||
req.body?.close()
|
||||
|
||||
@@ -73,6 +73,10 @@ class VideoLocal: IPlatformVideoDetails, IStoreItem {
|
||||
|
||||
override val isShort: Boolean get() = videoSerialized.isShort;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
//TODO: Offline subtitles
|
||||
override val subtitles: List<ISubtitleSource> = listOf();
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import com.caoccao.javet.exceptions.JavetCompilationException
|
||||
import com.caoccao.javet.exceptions.JavetException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
import com.caoccao.javet.interfaces.IJavetEntityError
|
||||
import com.caoccao.javet.interfaces.IJavetEntityMap
|
||||
import com.caoccao.javet.interop.V8Host
|
||||
import com.caoccao.javet.interop.V8Runtime
|
||||
import com.caoccao.javet.values.V8Value
|
||||
@@ -18,6 +20,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.engine.exceptions.NoInternetException
|
||||
import com.futo.platformplayer.engine.exceptions.PluginEngineStoppedException
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCompilationException
|
||||
@@ -33,9 +36,11 @@ import com.futo.platformplayer.engine.internal.V8Converter
|
||||
import com.futo.platformplayer.engine.packages.PackageBridge
|
||||
import com.futo.platformplayer.engine.packages.PackageDOMParser
|
||||
import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
import com.futo.platformplayer.engine.packages.PackageHttpImp
|
||||
import com.futo.platformplayer.engine.packages.PackageJSDOM
|
||||
import com.futo.platformplayer.engine.packages.PackageUtilities
|
||||
import com.futo.platformplayer.engine.packages.V8Package
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
@@ -242,10 +247,12 @@ class V8Plugin {
|
||||
}
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
_busyLock.lock();
|
||||
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
try {
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
_busyLock.unlock();
|
||||
}
|
||||
/*
|
||||
@@ -377,6 +384,7 @@ class V8Plugin {
|
||||
return when(packageName) {
|
||||
"DOMParser" -> PackageDOMParser(this)
|
||||
"Http" -> PackageHttp(this, config)
|
||||
"HttpImp" -> PackageHttpImp(this, config)
|
||||
"Utilities" -> PackageUtilities(this, config)
|
||||
"JSDOM" -> PackageJSDOM(this, config)
|
||||
else -> if(allowNull) null else throw ScriptCompilationException(config, "Unknown package [${packageName}] required for plugin ${config.name}");
|
||||
@@ -405,6 +413,12 @@ class V8Plugin {
|
||||
return _runtimeMap.getOrDefault(runtime, null);
|
||||
}
|
||||
|
||||
private fun ctxString(ctx: Any?, key: String): String? = when (ctx) {
|
||||
is Map<*, *> -> ctx[key]?.toString()
|
||||
is V8ValueObject -> if (ctx.has(key)) ctx.getString(key) else null
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun <T: Any?> catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T {
|
||||
var codeStripped = code;
|
||||
if(codeStripped != null) { //TODO: Improve code stripped
|
||||
@@ -438,37 +452,6 @@ class V8Plugin {
|
||||
throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
|
||||
}
|
||||
catch(executeEx: JavetExecutionException) {
|
||||
val obj = executeEx.scriptingError?.context
|
||||
if(obj != null && obj.containsKey("plugin_type") == true) {
|
||||
val pluginType = obj["plugin_type"].toString();
|
||||
|
||||
//Captcha
|
||||
if (pluginType == "CaptchaRequiredException") {
|
||||
throw ScriptCaptchaRequiredException(config,
|
||||
obj["url"]?.toString(),
|
||||
obj["body"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
//Reload Required
|
||||
if (pluginType == "ReloadRequiredException") {
|
||||
throw ScriptReloadRequiredException(config,
|
||||
obj["msg"]?.toString(),
|
||||
obj["reloadData"]?.toString(),
|
||||
executeEx, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
|
||||
//Others
|
||||
throwExceptionFromV8(
|
||||
config,
|
||||
pluginType,
|
||||
(extractJSExceptionMessage(executeEx) ?: ""),
|
||||
executeEx,
|
||||
executeEx.scriptingError?.stack,
|
||||
codeStripped
|
||||
);
|
||||
}
|
||||
/* //Required for newer V8 versions
|
||||
if(executeEx.scriptingError?.context is IJavetEntityError) {
|
||||
val obj = executeEx.scriptingError?.context as IJavetEntityError
|
||||
if(obj.context.containsKey("plugin_type") == true) {
|
||||
@@ -502,7 +485,6 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
||||
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
@@ -511,18 +493,29 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
private fun throwExceptionFromV8(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null) {
|
||||
throw getExceptionFromPlugin(config, pluginType, msg, innerEx, stack, code);
|
||||
}
|
||||
fun getExceptionFromPlugin(config: IV8PluginConfig, obj: V8ValueObject, innerEx: Exception? = null, stack: String? = null, code: String? = null, prefix: String? = null): PluginException {
|
||||
val pluginType = obj.getOrDefault(config, "plugin_type", "Exception Handling", "")?.let { if(!it.isNullOrBlank()) it + "" else "" } ?: "";
|
||||
var msg = obj.getOrDefault<String?>(config, "msg", "Exception Handling", null)
|
||||
?: obj.getOrDefault(config, "message", "Exception Handling", "");
|
||||
if(!prefix.isNullOrBlank())
|
||||
msg = prefix + msg;
|
||||
return getExceptionFromPlugin(config, pluginType, msg ?: "Unknown exception", innerEx, stack, code);
|
||||
}
|
||||
fun getExceptionFromPlugin(config: IV8PluginConfig, pluginType: String, msg: String, innerEx: Exception? = null, stack: String? = null, code: String? = null): PluginException {
|
||||
when(pluginType) {
|
||||
"ScriptException" -> throw ScriptException(config, msg, innerEx, stack, code);
|
||||
"CriticalException" -> throw ScriptCriticalException(config, msg, innerEx, stack, code);
|
||||
"AgeException" -> throw ScriptAgeException(config, msg, innerEx, stack, code);
|
||||
"UnavailableException" -> throw ScriptUnavailableException(config, msg, innerEx, stack, code);
|
||||
"ScriptLoginRequiredException" -> throw ScriptLoginRequiredException(config, msg, innerEx, stack, code);
|
||||
"ScriptExecutionException" -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptCompilationException" -> throw ScriptCompilationException(config, msg, innerEx, code);
|
||||
"ScriptImplementationException" -> throw ScriptImplementationException(config, msg, innerEx, null, code);
|
||||
"ScriptTimeoutException" -> throw ScriptTimeoutException(config, msg, innerEx);
|
||||
"NoInternetException" -> throw NoInternetException(config, msg, innerEx, stack, code);
|
||||
else -> throw ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptException" -> return ScriptException(config, msg, innerEx, stack, code);
|
||||
"CriticalException" -> return ScriptCriticalException(config, msg, innerEx, stack, code);
|
||||
"AgeException" -> return ScriptAgeException(config, msg, innerEx, stack, code);
|
||||
"UnavailableException" -> return ScriptUnavailableException(config, msg, innerEx, stack, code);
|
||||
"ScriptLoginRequiredException" -> return ScriptLoginRequiredException(config, msg, innerEx, stack, code);
|
||||
"ScriptExecutionException" -> return ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
"ScriptCompilationException" -> return ScriptCompilationException(config, msg, innerEx, code);
|
||||
"ScriptImplementationException" -> return ScriptImplementationException(config, msg, innerEx, null, code);
|
||||
"ScriptTimeoutException" -> return ScriptTimeoutException(config, msg, innerEx);
|
||||
"NoInternetException" -> return NoInternetException(config, msg, innerEx, stack, code);
|
||||
else -> return ScriptExecutionException(config, msg, innerEx, stack, code);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class V8RemoteObject {
|
||||
}
|
||||
|
||||
|
||||
fun List<V8RemoteObject>.serialize() : String {
|
||||
fun List<V8RemoteObject?>.serialize() : String {
|
||||
return _gson.toJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.curlbind
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.collections.iterator
|
||||
import kotlin.math.min
|
||||
|
||||
@Keep
|
||||
object Libcurl {
|
||||
init {
|
||||
System.loadLibrary("curl-impersonate")
|
||||
System.loadLibrary("curl-impersonate-jni")
|
||||
// CURL_GLOBAL_ALL = 3
|
||||
require(ce_global_init(3) == CURLcode.CURLE_OK) { "curl_global_init failed" }
|
||||
}
|
||||
|
||||
@Keep
|
||||
data class Request(
|
||||
var url: String,
|
||||
var method: String = "GET",
|
||||
var headers: Map<String, String> = emptyMap(),
|
||||
var body: ByteArray? = null,
|
||||
var impersonateTarget: String = "chrome136",
|
||||
var useBuiltInHeaders: Boolean = true,
|
||||
var timeoutMs: Int = 30_000,
|
||||
var cookieJarPath: String? = null,
|
||||
var sendCookies: Boolean = true,
|
||||
var persistCookies: Boolean = true,
|
||||
)
|
||||
|
||||
@Keep
|
||||
data class Response(
|
||||
val status: Int,
|
||||
val effectiveUrl: String,
|
||||
val bodyBytes: ByteArray,
|
||||
val headers: Map<String, List<String>>
|
||||
)
|
||||
|
||||
object CURLcode {
|
||||
const val CURLE_OK = 0
|
||||
const val CURLE_UNKNOWN_OPTION = 48
|
||||
}
|
||||
|
||||
object CurlInfoConsts {
|
||||
const val CURLINFO_STRING = 0x100000
|
||||
const val CURLINFO_LONG = 0x200000
|
||||
const val CURLINFO_DOUBLE = 0x300000
|
||||
const val CURLINFO_SLIST = 0x400000
|
||||
const val CURLINFO_PTR = 0x400000
|
||||
const val CURLINFO_SOCKET = 0x500000
|
||||
const val CURLINFO_OFF_T = 0x600000
|
||||
const val CURLINFO_MASK = 0x0fffff
|
||||
const val CURLINFO_TYPEMASK = 0xf00000
|
||||
}
|
||||
|
||||
object CURLINFO {
|
||||
const val NONE = 0
|
||||
const val EFFECTIVE_URL = CurlInfoConsts.CURLINFO_STRING + 1
|
||||
const val RESPONSE_CODE = CurlInfoConsts.CURLINFO_LONG + 2
|
||||
}
|
||||
|
||||
object CURLOPT {
|
||||
const val URL = 10002
|
||||
const val FOLLOWLOCATION = 52
|
||||
const val MAXREDIRS = 68
|
||||
const val CONNECTTIMEOUT_MS = 156
|
||||
const val TIMEOUT_MS = 155
|
||||
const val HTTP_VERSION = 84
|
||||
const val ACCEPT_ENCODING = 10102
|
||||
const val HTTPHEADER = 10023
|
||||
const val COOKIEFILE = 10031
|
||||
const val COOKIEJAR = 10082
|
||||
const val CUSTOMREQUEST = 10036
|
||||
const val IPRESOLVE = 113
|
||||
const val POSTFIELDS = 10015
|
||||
const val POSTFIELDSIZE = 60
|
||||
const val WRITEFUNCTION = 20011
|
||||
const val HEADERFUNCTION = 20079
|
||||
const val WRITEDATA = 10001
|
||||
const val HEADERDATA = 10029
|
||||
const val COPYPOSTFIELDS = 10165
|
||||
const val CURLOPT_DNS_SERVERS = 10211
|
||||
const val CAPATH = 10097
|
||||
const val CAINFO = 10065
|
||||
}
|
||||
|
||||
object CURL_HTTP_VERSION { const val TWO_TLS = 4 }
|
||||
object CURL_IPRESOLVE { const val WHATEVER = 0; const val V4 = 1; const val V6 = 2 }
|
||||
|
||||
@Keep interface WriteCallback { fun onWrite(chunk: ByteArray): Int }
|
||||
@Keep interface HeaderCallback { fun onHeader(line: ByteArray): Int }
|
||||
|
||||
@Volatile private var defaultCAPath: String? = null
|
||||
@Keep fun setDefaultCAPath(path: String) { defaultCAPath = path }
|
||||
|
||||
fun perform(req: Request): Response {
|
||||
val easy = ce_easy_init()
|
||||
require(easy != 0L) { "curl_easy_init failed" }
|
||||
|
||||
var slist: Long = 0L
|
||||
val bodySink = ByteArrayOutputStream(64 * 1024)
|
||||
val rawHeaderLines = ArrayList<String>(64)
|
||||
|
||||
try {
|
||||
val imp = ce_easy_impersonate(easy, req.impersonateTarget, req.useBuiltInHeaders)
|
||||
if (imp != CURLcode.CURLE_OK && imp != CURLcode.CURLE_UNKNOWN_OPTION) {
|
||||
error("curl_easy_impersonate failed: ${ce_easy_strerror(imp)}")
|
||||
}
|
||||
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.URL, req.url))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.FOLLOWLOCATION, 1))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.MAXREDIRS, 10))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.CONNECTTIMEOUT_MS, req.timeoutMs.toLong()))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.TIMEOUT_MS, req.timeoutMs.toLong()))
|
||||
checkOK(ce_setopt_long(easy, CURLOPT.HTTP_VERSION, CURL_HTTP_VERSION.TWO_TLS.toLong()))
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.ACCEPT_ENCODING, "")) // enable auto-decompress
|
||||
|
||||
if (req.headers.isNotEmpty()) {
|
||||
for ((k, v) in req.headers) slist = ce_slist_append(slist, "$k: $v")
|
||||
if (slist != 0L) checkOK(ce_setopt_ptr(easy, CURLOPT.HTTPHEADER, slist))
|
||||
}
|
||||
|
||||
if (req.sendCookies || req.persistCookies) {
|
||||
val jar = (req.cookieJarPath ?: defaultCookieJarPath())
|
||||
if (req.sendCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEFILE, jar))
|
||||
if (req.persistCookies) checkOK(ce_setopt_str(easy, CURLOPT.COOKIEJAR, jar))
|
||||
}
|
||||
|
||||
val method = req.method
|
||||
if (!method.equals("GET", ignoreCase = true)) {
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CUSTOMREQUEST, method))
|
||||
val body = req.body
|
||||
if (body != null && body.isNotEmpty()) {
|
||||
checkOK(ce_set_postfields(easy, body))
|
||||
}
|
||||
}
|
||||
|
||||
checkOK(ce_set_write_callback(easy, object : WriteCallback {
|
||||
override fun onWrite(chunk: ByteArray): Int {
|
||||
bodySink.write(chunk)
|
||||
return chunk.size
|
||||
}
|
||||
}))
|
||||
checkOK(ce_set_header_callback(easy, object : HeaderCallback {
|
||||
override fun onHeader(line: ByteArray): Int {
|
||||
// Keep raw but trim CRLF for convenience
|
||||
val s = line.toString(Charset.forName("ISO-8859-1")).trimEnd('\r', '\n')
|
||||
if (s.isNotBlank()) rawHeaderLines.add(s)
|
||||
return line.size
|
||||
}
|
||||
}))
|
||||
|
||||
checkOK(ce_setopt_str(easy, CURLOPT.CURLOPT_DNS_SERVERS, "1.1.1.1,8.8.8.8"));
|
||||
defaultCAPath?.let { checkOK(ce_setopt_str(easy, CURLOPT.CAINFO, it)) }
|
||||
|
||||
val rc = ce_easy_perform(easy)
|
||||
if (rc != CURLcode.CURLE_OK) error("curl_easy_perform failed: ${ce_easy_strerror(rc)}")
|
||||
|
||||
val codeArr = longArrayOf(0)
|
||||
checkOK(ce_easy_getinfo_long(easy, CURLINFO.RESPONSE_CODE, codeArr))
|
||||
val effective = ce_easy_getinfo_string(easy, CURLINFO.EFFECTIVE_URL) ?: req.url
|
||||
|
||||
return Response(
|
||||
status = codeArr[0].toInt(),
|
||||
effectiveUrl = effective,
|
||||
bodyBytes = bodySink.toByteArray(),
|
||||
headers = parseHeaders(rawHeaderLines)
|
||||
)
|
||||
} finally {
|
||||
if (slist != 0L) ce_slist_free_all(slist)
|
||||
ce_easy_cleanup(easy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultCookieJarPath(): String {
|
||||
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
|
||||
return if (tmp.endsWith("/")) "${tmp}imphttp.cookies.txt" else "$tmp/imphttp.cookies.txt"
|
||||
}
|
||||
|
||||
private fun checkOK(code: Int) {
|
||||
if (code != CURLcode.CURLE_OK) throw IllegalStateException("libcurl error: ${ce_easy_strerror(code)}")
|
||||
}
|
||||
|
||||
private fun parseHeaders(lines: List<String>): Map<String, List<String>> {
|
||||
val map = linkedMapOf<String, MutableList<String>>()
|
||||
for (line in lines) {
|
||||
val idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
val name = line.substring(0, idx).trim()
|
||||
val value = line.substring(min(idx + 1, line.length)).trim()
|
||||
map.getOrPut(name) { mutableListOf() }.add(value)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@JvmStatic external fun ce_set_write_callback(easy: Long, cb: WriteCallback?): Int
|
||||
@JvmStatic external fun ce_set_header_callback(easy: Long, cb: HeaderCallback?): Int
|
||||
|
||||
@JvmStatic external fun ce_global_init(flags: Long): Int
|
||||
@JvmStatic external fun ce_global_cleanup()
|
||||
@JvmStatic external fun ce_easy_init(): Long
|
||||
@JvmStatic external fun ce_easy_cleanup(easy: Long)
|
||||
@JvmStatic external fun ce_easy_perform(easy: Long): Int
|
||||
|
||||
@JvmStatic external fun ce_easy_impersonate(easy: Long, target: String, defaultHeaders: Boolean): Int
|
||||
@JvmStatic external fun ce_setopt_long(easy: Long, opt: Int, value: Long): Int
|
||||
@JvmStatic external fun ce_setopt_str(easy: Long, opt: Int, value: String): Int
|
||||
@JvmStatic external fun ce_setopt_ptr(easy: Long, opt: Int, ptr: Long): Int
|
||||
@JvmStatic external fun ce_slist_append(list: Long, header: String): Long
|
||||
@JvmStatic external fun ce_slist_free_all(list: Long)
|
||||
@JvmStatic external fun ce_easy_getinfo_long(easy: Long, info: Int, outVal: LongArray): Int
|
||||
@JvmStatic external fun ce_easy_getinfo_string(easy: Long, info: Int): String?
|
||||
|
||||
@JvmStatic external fun ce_set_postfields(easy: Long, body: ByteArray): Int
|
||||
@JvmStatic external fun ce_easy_strerror(code: Int): String
|
||||
}
|
||||
@@ -194,7 +194,11 @@ class PackageBridge : V8Package {
|
||||
|
||||
val stackTrace = Thread.currentThread().stackTrace;
|
||||
val callerMethod = stackTrace.findLast {
|
||||
it.className == JSClient::class.java.name
|
||||
it.className == JSClient::class.java.name &&
|
||||
it.methodName != "isBusy" &&
|
||||
it.methodName != "busy" &&
|
||||
it.methodName != "getCopy" &&
|
||||
it.methodName != "isBusyWith"
|
||||
}?.methodName ?: "";
|
||||
val session = StateApp.instance.sessionId;
|
||||
val pluginId = _plugin.config.id;
|
||||
|
||||
@@ -55,7 +55,7 @@ class PackageDOMParser : V8Package {
|
||||
}
|
||||
@V8Property
|
||||
fun lastChild(): DOMNode? {
|
||||
val result = _element.firstElementChild()?.let { DOMNode(_package, it) };
|
||||
val result = _element.lastElementChild()?.let { DOMNode(_package, it) };
|
||||
if(result != null)
|
||||
_children.add(result);
|
||||
return result;
|
||||
|
||||
@@ -254,7 +254,7 @@ class PackageHttp: V8Package {
|
||||
|
||||
//TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future.
|
||||
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
|
||||
class BatchBuilder(private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
|
||||
class BatchBuilder(@Transient private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
|
||||
@Transient
|
||||
private val _reqs = existingRequests;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user