Compare commits

...

75 Commits

Author SHA1 Message Date
Kelvin 4433364cd8 Fix build error 2025-11-13 23:46:57 +01:00
Kelvin 2c957d7188 Submods 2025-11-13 15:01:15 +01:00
Kelvin f229f4ed1f Merge branch 'wip-library' into 'master'
Library Support (On-device music & videos)

See merge request videostreaming/grayjay!154
2025-11-13 13:53:14 +00:00
Kelvin e8d1f73e29 Bottom bar highlighting change 2025-11-13 14:52:16 +01:00
Koen J dd2cf18cb2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-11-13 14:49:50 +01:00
Koen J 5355602577 Implemented httpimp. 2025-11-13 14:49:05 +01:00
Kelvin 8cc82e4d16 Possible fix for dropping live playback 2025-11-13 04:05:08 +01:00
Kelvin 4b5ed38175 Reset settings and share settings buttons 2025-11-13 01:23:43 +01:00
Kelvin 75eb7359de Fix various ref to old activity settings 2025-11-12 23:55:44 +01:00
Kelvin fd519d48cf Settings as fragments instead 2025-11-12 23:01:41 +01:00
Koen 6f1866ac27 Merge branch 'marcus/casting-stop-playback-before-disconnect' into 'master'
Casting: stop video playback before disconnecting from the active device

See merge request videostreaming/grayjay!152
2025-11-12 12:41:10 +00:00
Kelvin 0dc0f07785 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-11-11 23:16:36 +01:00
Kelvin bae8cb7bc4 Library search support 2025-11-11 23:16:16 +01:00
Kelvin d5a696289b Even more library work 2025-11-11 01:13:35 +01:00
Kelvin 75ef7085eb Library UI, artists listing, new album layout, etc 2025-11-09 23:43:13 +01:00
Kelvin 347ef855b3 Library continuation, disable auto backup ask, minor tweaks. 2025-11-08 19:02:38 +01:00
Koen J aac19aef86 Long press moved to layout comment. 2025-11-06 14:26:11 +01:00
Koen J 33efc5c21d Upgraded all dependencies and changed double tap to long press on comment text. 2025-11-06 14:25:27 +01:00
Koen J fc7001c295 Added double click to copy button on comments. 2025-11-06 11:29:24 +01:00
Koen J 9b68394f70 Added setting for persisting subtitles across multiple videos when the same language exists. 2025-11-05 15:02:33 +01:00
Koen J e2ef8c2593 Shorts player keep screen on interaction. 2025-11-05 12:18:47 +01:00
Koen J 551bfe44ac Loader game visible now allows going into pip automatically. 2025-11-05 09:04:01 +01:00
Koen J 6fbfa98ad3 Made the resume more persistent and not visible when loader game is visible. 2025-11-03 17:58:03 +01:00
Kelvin 9b97e05e3b File browser support 2025-10-30 21:19:47 +01:00
Kelvin da44e86163 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into wip-library 2025-10-23 01:59:30 +02:00
Kelvin 682b86330e Library work 2025-10-23 01:59:03 +02:00
Marcus Hanestad c9ba8a09e2 casting: stop video playback before disconnecting from device 2025-10-22 13:26:08 +02:00
Koen 7d19c2357c Merge branch 'aw/polycentric-moderation' into 'master'
Polycentric Moderation

See merge request videostreaming/grayjay!151
2025-10-16 11:13:27 +00:00
austin 64030a038c Polycentric Moderation 2025-10-16 11:13:27 +00:00
Kelvin 87d93c2ed8 WIP library support, albums, artists, videos 2025-10-15 01:03:47 +02:00
Koen 9d9ad52535 Merge branch 'marcus/exp-casting-device-pinning-fix' into 'master'
casting(experimental): ignore devices that are unsupported or fails to parse

See merge request videostreaming/grayjay!150
2025-10-14 07:10:45 +00:00
Marcus Hanestad b10cf6a323 casting(experimental): ignore devices that are unsupported or fails to parse 2025-10-09 09:41:32 +02:00
Koen J 4407e82d8a Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-08 16:39:14 +02:00
Koen J 3113dc53a6 fix: Session not authorized showing when it shouldn't 2025-10-08 16:38:10 +02:00
Koen bd25276720 Merge branch 'zvonimir-dev' into 'master'
fix: Session not authorized showing when it shouldn't

See merge request videostreaming/grayjay!149
2025-10-08 13:28:38 +00:00
z2rec 29d3a9986e fix: Session not authorized showing when it shouldn't 2025-10-08 15:27:34 +02:00
Koen J bce93b8e0f Changed intent codes for pip to match with intent codes from notification. 2025-10-07 16:21:37 +02:00
Koen 9a950958f9 Merge branch 'possible-ui-fixes' into 'master'
Possible ui fixes

See merge request videostreaming/grayjay!148
2025-10-06 17:40:35 +00:00
Kelvin b6676e7763 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-10-06 19:14:27 +02:00
Kelvin 35fe093e5c Convert promise cancel exceptions to conventional exceptions 2025-10-06 19:13:52 +02:00
Koen J 7cad4fbe07 Fixed crash in TextView drag drop. 2025-10-06 12:58:10 +02:00
Koen J 240772790d Possible fixes for other activities. 2025-10-06 11:51:04 +02:00
Koen J d659ecc518 Possible fixes for DownloadService issues. 2025-10-06 11:00:47 +02:00
Koen J 7d8bb20b71 Possible fixes for DownloadService issues. 2025-10-06 11:00:36 +02:00
Koen J 1cf5f776d5 Trial 1 2025-10-03 19:22:46 +02:00
Koen J 137ba85538 Sync pairing will now always happen in parallel for direct and relayed and reduced amount of occupied threads. 2025-10-03 14:11:48 +02:00
Kelvin 642d218c54 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 18:45:56 +02:00
Kelvin 26b5470200 Fix crash fix on async promise handling 2025-09-29 18:45:42 +02:00
Koen J 547fe7bc13 Updated target SDK. 2025-09-29 15:46:45 +02:00
Koen J 678305e366 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:28:31 +02:00
Koen J 9f07673d85 Updated compile SDK. 2025-09-29 15:27:51 +02:00
Kelvin 19429263a9 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 15:01:19 +02:00
Kelvin 986652adab Refs 2025-09-29 15:01:04 +02:00
Koen J 4d93a58d5d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2025-09-29 13:15:58 +02:00
Koen J 817c90f3af Translation fix. 2025-09-29 13:15:06 +02:00
Kelvin 77348b3787 Refs 2025-09-29 13:06:42 +02:00
Kelvin 31e26d03c6 Merge 2025-09-29 12:45:00 +02:00
Kelvin 1ef566ab16 Async fixes, local file playback support 2025-09-29 12:31:17 +02:00
Koen J 7597f5136c Fix Android getting stuck. 2025-09-26 13:46:43 +02:00
Koen 9a2a70622f Merge branch 'marcus/fcast-casting-sdk' into 'master'
Experimental casting backend

See merge request videostreaming/grayjay!145
2025-09-10 15:26:16 +00:00
Marcus Hanestad 4fc33411fd Experimental casting backend 2025-09-10 15:26:16 +00:00
Kelvin a9bb900994 Change when plugins are disabled on reload and listing reloads 2025-09-08 19:00:41 +02:00
Koen J 8c1a18d8b4 Build fixes. 2025-08-27 09:58:54 +02:00
Koen J 14ae5f1572 Fixed translations to align. 2025-08-26 21:32:13 +02:00
koen-futo ed40994600 Merge pull request #2357 from 0xrxL/master
Italian localization
2025-08-26 21:14:14 +02:00
koen-futo 90e8c35b19 Merge pull request #2096 from alpqn/master
Added Turkish Translations
2025-08-26 21:13:47 +02:00
Kelvin 4d017ad357 Refs 2025-08-24 21:41:07 +02:00
Kelvin 2ca2a9db23 Workaround for global lifetime scope unavailable 2025-08-24 19:35:15 +02:00
quonverbat 940bed2cee Merge branch 'futo-org:master' into master 2025-07-12 22:12:21 +03:00
0xrxL 4eb20a1843 Typo 2025-06-14 15:29:49 +02:00
0xrxL 98c6378148 Fix spacing 2025-06-14 09:04:40 +02:00
0xrxL bb066a7a31 Added italian localization 2025-06-14 09:02:25 +02:00
0xrxL b5d3261f03 Add files via upload 2025-06-14 09:01:41 +02:00
quonverbat 755bebaecb Merge branch 'futo-org:master' into master 2025-05-30 00:07:27 +03:00
quonverbat 004e4be4d3 Added Turkish Translations 2025-04-02 00:32:10 +03:00
208 changed files with 12679 additions and 1747 deletions
+4
View File
@@ -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
+50 -45
View File
@@ -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'
}
@@ -155,80 +156,84 @@ android {
dependencies {
//implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
implementation 'com.google.android.material:material:1.12.0'
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'
}
}
+27 -20
View File
@@ -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,58 @@
<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.NoActionBar" />
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
</application>
</manifest>
+1
View File
@@ -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);
@@ -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
@@ -42,7 +42,6 @@ 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
@@ -64,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))
}
}
@@ -73,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));
@@ -91,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
}
@@ -101,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
}
@@ -132,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) {
@@ -145,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);
}
@@ -154,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)
}
@@ -163,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;
@@ -203,6 +202,8 @@ class Settings : FragmentedStorageFileJson() {
8 -> "zh";
9 -> "ru";
10 -> "ar";
11 -> "it";
12 -> "tr";
else -> null
}
}
@@ -242,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");
}
}
@@ -372,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");
}
}
@@ -406,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;
@@ -717,6 +722,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 = false
/*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@DropdownFieldOptionsId(R.array.preferred_quality_array)
@@ -749,7 +759,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) {
@@ -766,7 +776,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)); };
}
}
@@ -834,13 +844,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);
}
}
@@ -849,7 +859,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") };
}
}
@@ -886,13 +896,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) {
@@ -904,7 +914,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) {
@@ -944,7 +954,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;
@@ -953,13 +963,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);
@@ -970,8 +980,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();
}),
@@ -987,11 +998,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()
@@ -1007,12 +1018,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();
}
})
}
@@ -1106,9 +1117,10 @@ class Settings : FragmentedStorageFileJson() {
@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() {
SettingsActivity.getActivity()?.let { context ->
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.",
@@ -1119,13 +1131,13 @@ class Settings : FragmentedStorageFileJson() {
UIDialogs.Action("Reset", {
syncServerUrl = null;
instance.save();
context.reloadSettings();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.ACCENT),
UIDialogs.Action.withInput("Configure", {
syncServerUrl = it?.text
instance.save();
context.reloadSettings();
SettingsFragment.currentView?.reloadSettings();
UIDialogs.toast("Sync server changes require a restart");
}, UIDialogs.ActionStyle.PRIMARY),
)
@@ -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");
}
@@ -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 {
@@ -331,15 +331,9 @@ class UISlideOverlays {
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);
StateApp.instance.activity?.let {
it.navigate(it.getFragment<SettingsFragment>(), mainContext.getString(R.string.background_update))
}
}, UIDialogs.ActionStyle.PRIMARY)
);
}
@@ -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;
}
}
}
@@ -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
@@ -34,11 +33,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.withStateAtLeast
import androidx.media3.common.util.UnstableApi
import com.curlbind.Libcurl
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 +53,27 @@ 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.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
@@ -75,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
@@ -146,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;
@@ -178,6 +192,16 @@ 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 _fragBrowser: BrowserFragment;
@@ -198,6 +222,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)
@@ -218,6 +243,17 @@ 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)
@@ -273,6 +309,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);
@@ -283,9 +320,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 {
@@ -300,6 +334,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);
@@ -316,6 +353,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_fragTopBarNavigation = NavigationTopBarFragment.newInstance();
_fragTopBarImport = ImportTopBarFragment.newInstance();
_fragTopBarAdd = AddTopBarFragment.newInstance();
_fragTopBarFiles = FilesTopBarFragment.newInstance();
//BotBars
_fragBotBarMenu = MenuBottomBarFragment.newInstance();
@@ -348,6 +386,16 @@ 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();
_fragBrowser = BrowserFragment.newInstance();
@@ -410,6 +458,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 {
@@ -474,6 +527,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;
@@ -638,6 +701,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,
@@ -696,17 +764,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) {
@@ -768,7 +832,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)
}
@@ -779,8 +843,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/")) {
@@ -807,11 +872,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",
{ });
}
@@ -932,6 +997,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;
}
@@ -1046,7 +1117,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)
@@ -1241,6 +1312,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;
@@ -1265,6 +1337,16 @@ 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;
else -> throw IllegalArgumentException("Fragment type ${T::class.java.name} is not available in MainActivity");
}
}
@@ -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);
@@ -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) {
@@ -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()
}
@@ -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()
}
@@ -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;
}
}
@@ -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;
@@ -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
@@ -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
);
@@ -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,
@@ -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;
@@ -7,6 +7,7 @@ interface ISubtitleSource {
val url: String?;
val format: String?;
val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?;
@@ -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;
}
}
}
@@ -103,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 = "";
@@ -147,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();
@@ -178,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)
@@ -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,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)
@@ -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;
}
}
}
@@ -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
}
@@ -22,6 +22,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 +30,7 @@ class JSSubtitleSource : ISubtitleSource {
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
@@ -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)"
}
@@ -31,42 +31,52 @@ 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
private var _pregenerate: V8Deferred<String?>? = null;
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;
@@ -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)"
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
@@ -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;
@@ -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;
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"
}
}
@@ -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,7 +109,11 @@ 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();
};
@@ -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
@@ -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 ->
@@ -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)
}
}
@@ -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);
}
}
@@ -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
}
@@ -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;
@@ -0,0 +1,787 @@
package com.futo.platformplayer.engine.packages
import com.caoccao.javet.annotations.V8Convert
import com.caoccao.javet.annotations.V8Function
import com.caoccao.javet.annotations.V8Property
import com.caoccao.javet.enums.V8ConversionMode
import com.caoccao.javet.enums.V8ProxyMode
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value
import com.caoccao.javet.values.primitive.V8ValueString
import com.caoccao.javet.values.reference.V8ValueTypedArray
import com.curlbind.Libcurl
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.engine.internal.IV8Convertable
import com.futo.platformplayer.engine.internal.V8BindObject
import com.futo.platformplayer.logging.Logger
import java.net.SocketTimeoutException
import java.nio.charset.Charset
import java.util.UUID
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask
import kotlin.math.min
class PackageHttpImp : V8Package {
@Transient
internal val _config: IV8PluginConfig
@Transient
private val _packageClient: PackageHttpClient
@Transient
private val _packageClientAuth: PackageHttpClient
override val name: String get() = "HttpImp"
override val variableName: String get() = "httpimp"
private var _batchPoolLock: Any = Any()
private var _batchPool: ForkJoinPool? = null
private val _clients = mutableMapOf<String, PackageHttpClient>()
constructor(plugin: V8Plugin, config: IV8PluginConfig) : super(plugin) {
_config = config
_packageClient = PackageHttpClient(this, withAuth = false)
_packageClientAuth = PackageHttpClient(this, withAuth = true)
}
fun cleanup() {
Logger.w(TAG, "PackageHttpImp Cleaning up")
}
private fun <T, R> autoParallelPool(
data: List<T>,
parallelism: Int,
handle: (T) -> R
): List<Pair<R?, Throwable?>> {
synchronized(_batchPoolLock) {
val threadsToUse = if (parallelism <= 0) data.size else min(parallelism, data.size)
if (_batchPool == null) {
_batchPool = ForkJoinPool(threadsToUse)
}
var pool = _batchPool ?: return listOf()
if (pool.poolSize < threadsToUse) {
pool.shutdown()
_batchPool = ForkJoinPool(threadsToUse)
pool = _batchPool ?: return listOf()
}
val resultTasks = mutableListOf<ForkJoinTask<Pair<R?, Throwable?>>>()
for (item in data) {
resultTasks.add(
pool.submit<Pair<R?, Throwable?>> {
try {
Pair(handle(item), null)
} catch (ex: Throwable) {
Pair<R?, Throwable?>(null, ex)
}
}
)
}
return resultTasks.map { it.join() }
}
}
@V8Function
fun newClient(withAuth: Boolean): PackageHttpClient {
val client = PackageHttpClient(this, withAuth)
client.clientId()?.let { _clients[it] = client }
return client
}
@V8Function
fun getDefaultClient(withAuth: Boolean): PackageHttpClient {
return if (withAuth) _packageClientAuth else _packageClient
}
fun getClient(id: String?): PackageHttpClient {
if (id == null) throw IllegalArgumentException("Http client $id doesn't exist")
if (_packageClient.clientId() == id) return _packageClient
if (_packageClientAuth.clientId() == id) return _packageClientAuth
return _clients[id] ?: throw IllegalArgumentException("Http client $id doesn't exist")
}
@V8Function
fun batch(): BatchBuilder {
return BatchBuilder(this)
}
@V8Function
fun request(
method: String,
url: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false,
bytesResult: Boolean = false
): IBridgeHttpResponse {
val client = if (useAuth) _packageClientAuth else _packageClient
return client.requestInternal(
method,
url,
headers,
if (bytesResult) ReturnType.BYTES else ReturnType.STRING
)
}
@V8Function
fun requestWithBody(
method: String,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false,
bytesResult: Boolean = false
): IBridgeHttpResponse {
val client = if (useAuth) _packageClientAuth else _packageClient
return client.requestWithBodyInternal(
method,
url,
body,
headers,
if (bytesResult) ReturnType.BYTES else ReturnType.STRING
)
}
@V8Function
fun GET(
url: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false,
useByteResponse: Boolean = false
): IBridgeHttpResponse {
val client = if (useAuth) _packageClientAuth else _packageClient
return client.GETInternal(
url,
headers,
if (useByteResponse) ReturnType.BYTES else ReturnType.STRING
)
}
@V8Function
fun POST(
url: String,
body: Any,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false,
useByteResponse: Boolean = false
): IBridgeHttpResponse {
val client = if (useAuth) _packageClientAuth else _packageClient
return when (body) {
is V8ValueString ->
client.POSTInternal(url, body.value, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
is String ->
client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
is V8ValueTypedArray ->
client.POSTInternal(url, body.toBytes(), headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
is ByteArray ->
client.POSTInternal(url, body, headers, if (useByteResponse) ReturnType.BYTES else ReturnType.STRING)
is ArrayList<*> ->
client.POSTInternal(
url,
body.map { (it as Double).toInt().toByte() }.toByteArray(),
headers,
if (useByteResponse) ReturnType.BYTES else ReturnType.STRING
)
else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST")
}
}
private fun <T> logExceptions(handle: () -> T): T {
try {
return handle()
} catch (ex: Exception) {
Logger.e("Plugin[${_config.name}]", ex.message, ex)
throw ex
}
}
interface IBridgeHttpResponse {
val url: String
val code: Int
val headers: Map<String, List<String>>?
}
@kotlinx.serialization.Serializable
class BridgeHttpStringResponse(
override val url: String,
override val code: Int,
val body: String?,
override val headers: Map<String, List<String>>? = null
) : IV8Convertable, IBridgeHttpResponse {
val isOk = code in 200..299
override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject()
obj.set("url", url)
obj.set("code", code)
obj.set("body", body)
obj.set("headers", headers)
obj.set("isOk", isOk)
return obj
}
}
@kotlinx.serialization.Serializable
class BridgeHttpBytesResponse(
override val url: String,
override val code: Int,
val body: ByteArray? = null,
override val headers: Map<String, List<String>>? = null
) : IV8Convertable, IBridgeHttpResponse {
val isOk: Boolean = code in 200..299
override fun toV8(runtime: V8Runtime): V8Value? {
val obj = runtime.createV8ValueObject()
obj.set("url", url)
obj.set("code", code)
if (body != null) {
obj.set("body", body)
}
obj.set("headers", headers)
obj.set("isOk", isOk)
return obj
}
}
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
class BatchBuilder(
@Transient private val _package: PackageHttpImp,
existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()
) : V8BindObject() {
@Transient
private val _reqs = existingRequests
@V8Function
fun request(
method: String,
url: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder {
return clientRequest(_package.getDefaultClient(useAuth).clientId(), method, url, headers)
}
@V8Function
fun requestWithBody(
method: String,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder {
return clientRequestWithBody(_package.getDefaultClient(useAuth).clientId(), method, url, body, headers)
}
@V8Function
fun GET(
url: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder =
clientGET(_package.getDefaultClient(useAuth).clientId(), url, headers)
@V8Function
fun POST(
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
useAuth: Boolean = false
): BatchBuilder =
clientPOST(_package.getDefaultClient(useAuth).clientId(), url, body, headers)
@V8Function
fun DUMMY(): BatchBuilder {
_reqs.add(
Pair(
_package.getDefaultClient(false),
RequestDescriptor("DUMMY", "", mutableMapOf())
)
)
return BatchBuilder(_package, _reqs)
}
@V8Function
fun clientRequest(
clientId: String?,
method: String,
url: String,
headers: MutableMap<String, String> = HashMap()
): BatchBuilder {
_reqs.add(Pair(_package.getClient(clientId), RequestDescriptor(method, url, headers)))
return BatchBuilder(_package, _reqs)
}
@V8Function
fun clientRequestWithBody(
clientId: String?,
method: String,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap()
): BatchBuilder {
_reqs.add(
Pair(
_package.getClient(clientId),
RequestDescriptor(method, url, headers, body)
)
)
return BatchBuilder(_package, _reqs)
}
@V8Function
fun clientGET(
clientId: String?,
url: String,
headers: MutableMap<String, String> = HashMap()
): BatchBuilder =
clientRequest(clientId, "GET", url, headers)
@V8Function
fun clientPOST(
clientId: String?,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap()
): BatchBuilder =
clientRequestWithBody(clientId, "POST", url, body, headers)
@V8Function
fun execute(): List<IBridgeHttpResponse?> {
return _package.autoParallelPool(_reqs, -1) {
if (it.second.method == "DUMMY") {
return@autoParallelPool null
}
if (it.second.body != null) {
it.first.requestWithBodyInternal(
it.second.method,
it.second.url,
it.second.body!!,
it.second.headers,
it.second.respType
)
} else {
it.first.requestInternal(
it.second.method,
it.second.url,
it.second.headers,
it.second.respType
)
}
}.map {
if (it.second != null) throw it.second!!
it.first
}.toList()
}
}
@V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
class PackageHttpClient : V8BindObject {
@Transient
private val _package: PackageHttpImp
@Transient
private val _withAuth: Boolean
val parentConfig: IV8PluginConfig
get() = _package._config
@Transient
private val _defaultHeaders = mutableMapOf<String, String>()
@Transient
private val _clientId: String = UUID.randomUUID().toString()
@Volatile
private var timeoutMs: Int = 30_000
@Volatile
private var sendCookies: Boolean = true
@Volatile
private var persistCookies: Boolean = true
@Volatile
private var cookieJarPath: String? = null
@Volatile
private var impersonateTarget: String = "chrome136"
@Volatile
private var useBuiltInHeaders: Boolean = true
@V8Property
fun clientId(): String? = _clientId
constructor(pack: PackageHttpImp, withAuth: Boolean) : super() {
_package = pack
_withAuth = withAuth
}
private fun ensureCookieJarPath(): String {
val existing = cookieJarPath
if (existing != null) return existing
val tmp = System.getProperty("java.io.tmpdir") ?: "/data/local/tmp"
val safeName = parentConfig.name.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val fileName =
if (_withAuth) "imphttp.$safeName.auth.cookies.txt" else "imphttp.$safeName.cookies.txt"
val path = if (tmp.endsWith("/")) tmp + fileName else "$tmp/$fileName"
cookieJarPath = path
return path
}
@V8Function
fun setDefaultHeaders(defaultHeaders: Map<String, String>) {
synchronized(_defaultHeaders) {
for (pair in defaultHeaders) {
_defaultHeaders[pair.key] = pair.value
}
}
}
@V8Function
fun setDoApplyCookies(apply: Boolean) {
sendCookies = apply
}
@V8Function
fun setDoUpdateCookies(update: Boolean) {
persistCookies = update
}
@V8Function
fun setDoAllowNewCookies(allow: Boolean) {
persistCookies = allow
}
@V8Function
fun setTimeout(timeoutMs: Int) {
this.timeoutMs = timeoutMs
}
@V8Function
fun request(
method: String,
url: String,
headers: MutableMap<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse =
requestInternal(method, url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
fun requestInternal(
method: String,
url: String,
headers: MutableMap<String, String> = HashMap(),
returnType: ReturnType
): IBridgeHttpResponse {
applyDefaultHeaders(headers)
return logExceptions {
catchHttp {
val resp = performCurl(method, url, headers, null)
responseToBridge(resp, returnType)
}
}
}
@V8Function
fun requestWithBody(
method: String,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse =
requestWithBodyInternal(
method,
url,
body,
headers,
if (useBytes) ReturnType.BYTES else ReturnType.STRING
)
fun requestWithBodyInternal(
method: String,
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
returnType: ReturnType
): IBridgeHttpResponse {
applyDefaultHeaders(headers)
return logExceptions {
catchHttp {
val resp = performCurl(method, url, headers, body.toByteArray())
responseToBridge(resp, returnType)
}
}
}
@V8Function
fun GET(
url: String,
headers: MutableMap<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse =
GETInternal(url, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
fun GETInternal(
url: String,
headers: MutableMap<String, String> = HashMap(),
returnType: ReturnType = ReturnType.STRING
): IBridgeHttpResponse {
applyDefaultHeaders(headers)
return logExceptions {
catchHttp {
val resp = performCurl("GET", url, headers, null)
responseToBridge(resp, returnType)
}
}
}
@V8Function
fun POST(
url: String,
body: Any,
headers: MutableMap<String, String> = HashMap(),
useBytes: Boolean = false
): IBridgeHttpResponse {
return when (body) {
is V8ValueString ->
POSTInternal(url, body.value, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
is String ->
POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
is V8ValueTypedArray ->
POSTInternal(url, body.toBytes(), headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
is ByteArray ->
POSTInternal(url, body, headers, if (useBytes) ReturnType.BYTES else ReturnType.STRING)
is ArrayList<*> ->
POSTInternal(
url,
body.map { (it as Double).toInt().toByte() }.toByteArray(),
headers,
if (useBytes) ReturnType.BYTES else ReturnType.STRING
)
else -> throw NotImplementedError("Body type ${body?.javaClass?.name} not implemented for POST")
}
}
fun POSTInternal(
url: String,
body: String,
headers: MutableMap<String, String> = HashMap(),
returnType: ReturnType = ReturnType.STRING
): IBridgeHttpResponse {
applyDefaultHeaders(headers)
return logExceptions {
catchHttp {
val resp = performCurl("POST", url, headers, body.toByteArray())
responseToBridge(resp, returnType)
}
}
}
fun POSTInternal(
url: String,
body: ByteArray,
headers: MutableMap<String, String> = HashMap(),
returnType: ReturnType = ReturnType.STRING
): IBridgeHttpResponse {
applyDefaultHeaders(headers)
return logExceptions {
catchHttp {
val resp = performCurl("POST", url, headers, body)
responseToBridge(resp, returnType)
}
}
}
private fun performCurl(
method: String,
url: String,
headers: Map<String, String>,
bodyBytes: ByteArray?
): Libcurl.Response {
val jar = ensureCookieJarPath()
val req = Libcurl.Request(
url = url,
method = method,
headers = headers,
body = bodyBytes,
impersonateTarget = impersonateTarget,
useBuiltInHeaders = useBuiltInHeaders,
timeoutMs = timeoutMs,
cookieJarPath = jar,
sendCookies = sendCookies,
persistCookies = persistCookies
)
return Libcurl.perform(req)
}
private fun responseToBridge(
resp: Libcurl.Response,
returnType: ReturnType
): IBridgeHttpResponse {
val sanitizedHeaders = sanitizeResponseHeaders(resp.headers, shouldWhitelistHeaders())
return when (returnType) {
ReturnType.STRING -> {
val bodyStr = decodeBody(resp)
BridgeHttpStringResponse(resp.effectiveUrl, resp.status, bodyStr, sanitizedHeaders)
}
ReturnType.BYTES -> {
BridgeHttpBytesResponse(resp.effectiveUrl, resp.status, resp.bodyBytes, sanitizedHeaders)
}
}
}
private fun decodeBody(resp: Libcurl.Response): String {
if (resp.bodyBytes.isEmpty()) return ""
val contentTypeHeader = resp.headers.entries.firstOrNull {
it.key.equals("content-type", ignoreCase = true)
}?.value?.firstOrNull()
val charset: Charset = contentTypeHeader
?.let { parseCharset(it) }
?: Charsets.UTF_8
return String(resp.bodyBytes, charset)
}
private fun parseCharset(contentType: String): Charset? {
val parts = contentType.split(";")
for (part in parts) {
val trimmed = part.trim()
val lower = trimmed.lowercase()
if (lower.startsWith("charset=")) {
val value = trimmed.substringAfter("=", "").trim().trim('"', '\'')
return try {
Charset.forName(value)
} catch (e: Exception) {
null
}
}
}
return null
}
private fun shouldWhitelistHeaders(): Boolean {
val cfg = parentConfig
return !(cfg is SourcePluginConfig && cfg.allowAllHttpHeaderAccess)
}
private fun applyDefaultHeaders(headerMap: MutableMap<String, String>) {
synchronized(_defaultHeaders) {
for (toApply in _defaultHeaders) {
if (!headerMap.containsKey(toApply.key)) {
headerMap[toApply.key] = toApply.value
}
}
}
}
private fun sanitizeResponseHeaders(
headers: Map<String, List<String>>?,
onlyWhitelisted: Boolean = false
): Map<String, List<String>> {
val result = mutableMapOf<String, List<String>>()
if (onlyWhitelisted) {
headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase()
if (WHITELISTED_RESPONSE_HEADERS.contains(lowerCaseHeader)) {
result[lowerCaseHeader] = values
}
}
} else {
headers?.forEach { (header, values) ->
val lowerCaseHeader = header.lowercase()
if (lowerCaseHeader == "set-cookie" &&
!values.any { it.lowercase().contains("httponly") }
) {
result[lowerCaseHeader] = values
} else {
result[lowerCaseHeader] = values
}
}
}
return result
}
private fun logRequest(
method: String,
url: String,
headers: Map<String, String> = HashMap(),
body: String?
) {
Logger.v(TAG) {
val sb = StringBuilder()
sb.appendLine("HTTP request (libcurl)")
sb.appendLine("$method $url")
for (pair in headers) {
sb.appendLine("${pair.key}: ${pair.value}")
}
if (body != null) {
sb.appendLine()
sb.appendLine(body)
}
sb.toString()
}
}
fun <T> logExceptions(handle: () -> T): T {
try {
return handle()
} catch (ex: Exception) {
Logger.e("Plugin[${_package._config.name}]", ex.message, ex)
throw ex
}
}
private fun catchHttp(handle: () -> IBridgeHttpResponse): IBridgeHttpResponse {
return try {
handle()
} catch (ex: SocketTimeoutException) {
BridgeHttpStringResponse("", 408, null)
}
}
}
data class RequestDescriptor(
val method: String,
val url: String,
val headers: MutableMap<String, String>,
val body: String? = null,
val contentType: String? = null,
val respType: ReturnType = ReturnType.STRING
)
private fun catchHttp(handle: () -> BridgeHttpStringResponse): BridgeHttpStringResponse {
return try {
handle()
} catch (ex: SocketTimeoutException) {
BridgeHttpStringResponse("", 408, null)
}
}
enum class ReturnType(val value: Int) {
STRING(0),
BYTES(1);
}
companion object {
private const val TAG = "PackageHttpImp"
private val WHITELISTED_RESPONSE_HEADERS = listOf(
"content-type",
"date",
"content-length",
"last-modified",
"etag",
"cache-control",
"content-encoding",
"content-disposition",
"connection"
)
}
}
@@ -20,7 +20,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.MainActivityFragment
import com.futo.platformplayer.fragment.mainactivity.main.*
@@ -143,6 +142,10 @@ class MenuBottomBarFragment : MainActivityFragment() {
moreOverlay.visibility = VISIBLE
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 0.0f, 1.0f).setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 0.4f, 1.0f)
.setDuration(duration));
}
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
@@ -158,7 +161,13 @@ class MenuBottomBarFragment : MainActivityFragment() {
animatorSet.start()
} else {
val animations = arrayListOf<Animator>()
animations.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f).setDuration(duration))
animations
.add(ObjectAnimator.ofFloat(moreOverlayBackground, "alpha", 1.0f, 0.0f)
.setDuration(duration))
_bottomButtons.find { it.definition.id == 99 }?.let {
animations.add(ObjectAnimator.ofFloat(it, "alpha", 1.0f, 0.4f)
.setDuration(duration));
}
for ((index, button) in _moreButtons.withIndex()) {
val i = _moreButtons.size - index
@@ -260,7 +269,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
for(button in _bottomButtons.toList())
button.updateActive(_fragment);
for(button in _moreButtons.toList())
button.updateActive(_fragment);
button.updateActive(_fragment, true);
}
override fun onConfigurationChanged(newConfig: Configuration?) {
@@ -354,7 +363,14 @@ class MenuBottomBarFragment : MainActivityFragment() {
this.definition = def;
_buttonImage = findViewById(R.id.image_button);
_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
//_buttonImage.setImageResource(if (def.isActive(fragment)) def.iconActive else def.icon);
_buttonImage.setImageResource(definition.iconActive);
if(definition.isActive(fragment) || isMore) {
this.alpha = 1f;
}
else {
this.alpha = 0.4f;
}
_textButton = findViewById(R.id.text_button);
_textButton.text = resources.getString(def.string);
@@ -365,8 +381,16 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
}
fun updateActive(fragment: MenuBottomBarFragment) {
_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
fun updateActive(fragment: MenuBottomBarFragment, isMore: Boolean = false, overrideValue: Boolean? = null) {
//_buttonImage.setImageResource(if (definition.isActive(fragment)) definition.iconActive else definition.icon);
_buttonImage.setImageResource(definition.iconActive);
val isActive = overrideValue ?: definition.isActive(fragment) || isMore
if(isActive) {
this.alpha = 1f;
}
else {
this.alpha = 0.4f;
}
}
}
}
@@ -389,6 +413,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
}
}),
ButtonDefinition(1, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscriptions, canToggle = true, { it.currentMain is SubscriptionsFeedFragment }, { it.navigate<SubscriptionsFeedFragment>(withHistory = false) }),
ButtonDefinition(12, R.drawable.ic_library, R.drawable.ic_library, R.string.library, canToggle = false, { it.currentMain is LibraryFragment }, { it.navigate<LibraryFragment>(withHistory = false) }),
ButtonDefinition(2, R.drawable.ic_creators, R.drawable.ic_creators_filled, R.string.creators, canToggle = false, { it.currentMain is CreatorsFragment }, { it.navigate<CreatorsFragment>(withHistory = false) }),
ButtonDefinition(3, R.drawable.ic_sources, R.drawable.ic_sources_filled, R.string.sources, canToggle = false, { it.currentMain is SourcesFragment }, { it.navigate<SourcesFragment>(withHistory = false) }),
ButtonDefinition(4, R.drawable.ic_playlist, R.drawable.ic_playlist_filled, R.string.playlists, canToggle = false, { it.currentMain is PlaylistsFragment }, { it.navigate<PlaylistsFragment>(withHistory = false) }),
@@ -399,6 +424,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
ButtonDefinition(9, R.drawable.ic_subscriptions, R.drawable.ic_subscriptions_filled, R.string.subscription_group_menu, canToggle = true, { it.currentMain is SubscriptionGroupListFragment }, { it.navigate<SubscriptionGroupListFragment>(withHistory = false) }),
ButtonDefinition(10, R.drawable.ic_help_square, R.drawable.ic_help_square_fill, R.string.tutorials, canToggle = true, { it.currentMain is TutorialFragment }, { it.navigate<TutorialFragment>(withHistory = false) }),
ButtonDefinition(7, R.drawable.ic_settings, R.drawable.ic_settings_filled, R.string.settings, canToggle = false, { false }, {
it.navigate<SettingsFragment>();
/*
val c = it.context ?: return@ButtonDefinition;
Logger.i(TAG, "settings preventPictureInPicture()");
it.requireFragment<VideoDetailFragment>().preventPictureInPicture();
@@ -406,7 +433,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
c.startActivity(intent);
if (c is Activity) {
c.overridePendingTransition(R.anim.slide_in_up, R.anim.slide_darken);
}
}*/
}),
ButtonDefinition(96, R.drawable.ic_disabled_visible, R.drawable.ic_disabled_visible, R.string.privacy_mode, canToggle = true, { false }, {
UIDialogs.showDialog(it.context ?: return@ButtonDefinition, R.drawable.ic_disabled_visible_purple, "Privacy Mode",
@@ -64,7 +64,7 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
player.modifyState("ThumbnailPlayer") { state -> state.muted = true };
_exoPlayer = player;
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
return PreviewContentListAdapter(fragment.lifecycleScope, context, feedStyle ?: FeedStyle.THUMBNAIL, dataset, player, _previewsEnabled, arrayListOf(), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this);
}
}
@@ -0,0 +1,91 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import com.futo.platformplayer.R
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.IField
class DeveloperFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val newView = FragView(this);
view = newView;
_currentView = view;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
_currentView = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = DeveloperFragment().apply {}
private var _currentView: FragView? = null;
val currentView: FragView?
get() = _currentView;
}
class FragView: ConstraintLayout {
val fragment: DeveloperFragment;
private lateinit var _form: FieldForm;
private lateinit var _buttonBack: ImageButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
constructor(fragment: DeveloperFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_dev, this);
this.fragment = fragment;
val activity = fragment.activity;
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
_buttonBack = findViewById(R.id.button_back);
_form = findViewById(R.id.settings_form);
_form.fromObject(SettingsDev.instance);
_form.onChanged.subscribe { _, _ ->
_form.setObjectValues();
SettingsDev.instance.save();
};
}
fun getField(id: String): IField? {
return _form.findField(id);
}
fun onShown() {
}
}
}
@@ -39,6 +39,7 @@ import java.time.OffsetDateTime
import kotlin.math.max
abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : LinearLayout where TPager : IPager<TResult>, TViewHolder : RecyclerView.ViewHolder, TFragment : MainFragment {
protected val _feedRoot: FrameLayout;
protected val _recyclerResults: RecyclerView;
protected val _overlayContainer: FrameLayout;
protected val _swipeRefresh: SwipeRefreshLayout;
@@ -67,7 +68,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
private var _sortByOptions: List<String>? = null;
private var _activeTags: List<String>? = null;
private var _nextPageHandler: TaskHandler<TPager, List<TResult>>;
private var _nextPageHandler: TaskHandler<TPager, Pair<TPager, List<TResult>>>;
val recyclerData: RecyclerData<InsertedViewAdapterWithLoader<TViewHolder>, GridLayoutManager, TPager, TResult, TConverted, InsertedViewHolder<TViewHolder>>;
val fragment: TFragment;
@@ -80,6 +81,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
this.fragment = fragment;
inflater.inflate(R.layout.fragment_feed, this);
_feedRoot = findViewById(R.id.feed_root);
_textCentered = findViewById(R.id.text_centered);
_emptyPagerContainer = findViewById(R.id.empty_pager_container);
_progressBar = findViewById(R.id.progress_bar);
@@ -135,23 +137,27 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content);
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
_nextPageHandler = TaskHandler<TPager, Pair<TPager, List<TResult>>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>)
it.nextPageAsync();
else
it.nextPage();
processPagerExceptions(it);
return@TaskHandler it.getResults();
return@TaskHandler Pair(it, it.getResults());
}).success {
val pager = it.first;
val results = it.second
setLoading(false);
val posBefore = recyclerData.results.size;
val filteredResults = filterResults(it);
val filteredResults = filterResults(results);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
recyclerData.resultsUnfiltered.addAll(results);
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
ensureEnoughContentVisible(filteredResults)
if(pager.hasMorePages())
ensureEnoughContentVisible(filteredResults)
}.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -390,6 +396,9 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
protected fun finishRefreshLayoutLoader() {
_swipeRefresh.isRefreshing = false;
}
protected fun disableRefreshLayout() {
_swipeRefresh.isEnabled = false;
}
fun clearResults(){
setPager(EmptyPager<TResult>() as TPager);
@@ -0,0 +1,159 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.toHumanDuration
import com.futo.platformplayer.views.AlbumHeaderView
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
class LibraryAlbumFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this, inflater);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryAlbumFragment().apply {}
}
class FragView : FeedView<LibraryAlbumFragment, IPlatformVideo, IPlatformVideo, IPager<IPlatformVideo>, TrackViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
private val _header: AlbumHeaderView;
private var _album: Album? = null;
private var _tracks: List<IPlatformVideo>? = null;
private var _url: String? = null;
constructor(fragment: LibraryAlbumFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_header = AlbumHeaderView(context);
_toolbarContentView.addView(_header);
_header.onPlayAll.subscribe {
val playlist = _album?.toPlaylist(_tracks);
if (playlist != null) {
StatePlayer.instance.setPlaylist(playlist, focus = true);
}
}
_header.onShuffle.subscribe {
val playlist = _album?.toPlaylist(_tracks);
if (playlist != null) {
StatePlayer.instance.setPlaylist(playlist, focus = true, shuffle = true);
}
}
/*
_feedRoot.updateLayoutParams<LinearLayout.LayoutParams> {
this.setMargins(0,-50.dp(resources),0,0)
} */
}
fun onShown(parameter: Any?) {
val album = if(parameter is String)
StateLibrary.instance.getAlbum(parameter);
else if(parameter is Long)
StateLibrary.instance.getAlbum(parameter);
else if(parameter is Album)
parameter;
else null;
if(album == null) {
_album = null;
_tracks = null;
setPager(EmptyPager());
return;
}
_header.setName(album.name);
_header.setThumbnail(album.thumbnail);
val tracks = album.getTracks();
_album = album;
_tracks = tracks;
_header.setMetadata("${tracks.size} tracks" + if(tracks.size > 0) ("" + tracks.sumOf { it.duration }.toHumanDuration(false)) else "");
setPager(AdhocPager({listOf()}, tracks));
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = TrackViewHolder(viewGroup);
holder.onClick.subscribe { c ->
val playlist = _album?.toPlaylist(_tracks);
val index = playlist?.videos?.indexOfFirst { it.name == c.name } ?: -1;
if (playlist != null) {
if (index == -1)
return@subscribe;
StatePlayer.instance.setPlaylist(playlist, index, true);
}
};
holder.onOptions.subscribe {
if(it is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryArtistsFragmentsView";
}
}
}
@@ -0,0 +1,185 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryAlbumsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryAlbumsFragment().apply {}
}
class FragView : FeedView<LibraryAlbumsFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val libraryTypeHeader: LibraryTypeHeaderView;
constructor(fragment: LibraryAlbumsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
libraryTypeHeader = LibraryTypeHeaderView(context);
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Albums);
libraryTypeHeader.setMetadata("");
libraryTypeHeader.onSelectedChanged.subscribe {
when(it) {
LibraryTypeHeaderView.SelectedType.Artists -> fragment.navigate<LibraryArtistsFragment>();
else -> {}
}
}
_toolbarContentView.addView(libraryTypeHeader);
disableRefreshLayout();
}
fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
libraryTypeHeader.setMetadata("${initialAlbums.size} albums");
setPager(AdhocPager<Album>({ listOf(); }, initialAlbums));
}
override fun reload() {
super.reload();
finishRefreshLayoutLoader();
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = AlbumTileViewHolder(viewGroup);
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
leftMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
3f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class AlbumViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Album>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_album,
_viewGroup, false)) {
val onClick = Event1<Album?>();
protected var _album: Album? = null;
protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { onClick.emit(_album) };
}
override fun bind(album: Album) {
_album = album;
_imageThumbnail?.let {
if (album.thumbnail != null)
Glide.with(it)
.load(album.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
else
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
};
_textName.text = album.name;
_textMetadata.text = album.artist ?: "";
}
}
}
@@ -0,0 +1,617 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
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.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.assume
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.Event3
import com.futo.platformplayer.fragment.mainactivity.main.LibraryAlbumsFragment.AlbumViewHolder
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.SearchType
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LibraryArtistFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _textMeta: TextView? = null;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter, isBack);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryArtistFragment().apply {}
}
class FragView(fragment: LibraryArtistFragment, inflater: LayoutInflater) : LinearLayout(inflater.context) {
private val _fragment: LibraryArtistFragment = fragment
private var _textChannel: TextView
private var _textChannelSub: TextView
private var _creatorThumbnail: CreatorThumbnail
private var _imageBanner: AppCompatImageView
private var _tabs: TabLayout
private var _viewPager: ViewPager2
// private var _adapter: ChannelViewPagerAdapter;
private var _tabLayoutMediator: TabLayoutMediator
private var _buttonSubscribe: SubscribeButton
private var _buttonSubscriptionSettings: ImageButton
private var _overlayContainer: FrameLayout
private var _overlayLoading: LinearLayout
private var _overlayLoadingSpinner: ImageView
private var _slideUpOverlay: SlideUpMenuOverlay? = null
private var _isLoading: Boolean = false
private var _selectedTabIndex: Int = -1
var channel: Artist? = null
private set
private var _url: String? = null
private val _onPageChangeCallback = object : ViewPager2.OnPageChangeCallback() {}
init {
inflater.inflate(R.layout.fragment_artist, this)
val tabs: TabLayout = findViewById(R.id.tabs)
val viewPager: ViewPager2 = findViewById(R.id.view_pager)
_textChannel = findViewById(R.id.text_channel_name)
_textChannelSub = findViewById(R.id.text_metadata)
_creatorThumbnail = findViewById(R.id.creator_thumbnail)
_imageBanner = findViewById(R.id.image_channel_banner)
_buttonSubscribe = findViewById(R.id.button_subscribe)
_buttonSubscriptionSettings = findViewById(R.id.button_sub_settings)
_overlayLoading = findViewById(R.id.channel_loading_overlay)
_overlayLoadingSpinner = findViewById(R.id.channel_loader_frag)
_overlayContainer = findViewById(R.id.overlay_container)
_buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
}
_buttonSubscriptionSettings.setOnClickListener {
val url = channel?.contentUrl ?: _url ?: return@setOnClickListener
val sub =
StateSubscriptions.instance.getSubscription(url) ?: return@setOnClickListener
UISlideOverlays.showSubscriptionOptionsOverlay(sub, _overlayContainer)
}
//TODO: Determine if this is really the only solution (isSaveEnabled=false)
viewPager.isSaveEnabled = false
viewPager.registerOnPageChangeCallback(_onPageChangeCallback)
val adapter = ArtistViewPagerAdapter(fragment, fragment.childFragmentManager, fragment.lifecycle)
adapter.onChannelClicked.subscribe { c -> fragment.navigate<ChannelFragment>(c) }
adapter.onContentClicked.subscribe { v, _ ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(v).maximizeVideoDetail()
}
is IPlatformPlaylist -> {
fragment.navigate<RemotePlaylistFragment>(v)
}
is IPlatformPost -> {
fragment.navigate<PostDetailFragment>(v)
}
}
}
adapter.onShortClicked.subscribe { v, _, pagerPair ->
when (v) {
is IPlatformVideo -> {
StatePlayer.instance.clearQueue()
fragment.navigate<ShortsFragment>(Triple(v, pagerPair!!.first, pagerPair.second))
}
}
}
adapter.onAddToClicked.subscribe { content ->
_overlayContainer.let {
if (content is IPlatformVideo) _slideUpOverlay =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
adapter.onAddToQueueClicked.subscribe { content ->
if (content is IPlatformVideo) {
StatePlayer.instance.addToQueue(content)
}
}
adapter.onAddToWatchLaterClicked.subscribe { content ->
if (content is IPlatformVideo) {
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
UIDialogs.toast("Added to watch later\n[${content.name}]")
else
UIDialogs.toast(context.getString(R.string.already_in_watch_later))
}
}
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url)
}
adapter.onContentUrlClicked.subscribe { url, contentType ->
when (contentType) {
ContentType.MEDIA -> {
StatePlayer.instance.clearQueue()
fragment.navigate<VideoDetailFragment>(url).maximizeVideoDetail()
}
ContentType.URL -> fragment.navigate<BrowserFragment>(url)
else -> {}
}
}
adapter.onLongPress.subscribe { content ->
_overlayContainer.let {
if (content is IPlatformVideo) _slideUpOverlay =
UISlideOverlays.showVideoOptionsOverlay(content, it)
}
}
viewPager.adapter = adapter
val tabLayoutMediator = TabLayoutMediator(
tabs, viewPager, (viewPager.adapter as ArtistViewPagerAdapter)::getTabNames
)
tabLayoutMediator.attach()
_tabLayoutMediator = tabLayoutMediator
_tabs = tabs
_viewPager = viewPager
if (_selectedTabIndex != -1) {
selectTab(_selectedTabIndex)
}
setLoading(true)
}
fun selectTab(tab: ArtistTab) {
(_viewPager.adapter as ArtistViewPagerAdapter).getTabPosition(tab)
}
fun cleanup() {
_tabLayoutMediator.detach()
_viewPager.unregisterOnPageChangeCallback(_onPageChangeCallback)
hideSlideUpOverlay()
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
}
fun onShown(parameter: Any?, isBack: Boolean) {
hideSlideUpOverlay()
_selectedTabIndex = -1
if (!isBack || _url == null) {
_imageBanner.setImageDrawable(null)
when (parameter) {
is String -> {
_buttonSubscribe.setSubscribeChannel(parameter)
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
_url = parameter
val parsed = Uri.parse(parameter);
val idLong = parsed.lastPathSegment?.toLongOrNull();
if(idLong != null) {
val artist = StateLibrary.instance.getArtist(idLong) ?: return;
showArtist(artist);
}
}
is Artist -> {
showArtist(parameter)
_url = parameter.contentUrl
}
}
}
}
private fun selectTab(selectedTabIndex: Int) {
_selectedTabIndex = selectedTabIndex
_tabs.selectTab(_tabs.getTabAt(selectedTabIndex))
}
private fun setLoading(isLoading: Boolean) {
if (_isLoading == isLoading) {
return
}
_isLoading = isLoading
if (isLoading) {
_overlayLoading.visibility = View.VISIBLE
(_overlayLoadingSpinner.drawable as Animatable?)?.start()
} else {
(_overlayLoadingSpinner.drawable as Animatable?)?.stop()
_overlayLoading.visibility = View.GONE
}
}
fun onBackPressed(): Boolean {
if (_slideUpOverlay != null) {
hideSlideUpOverlay()
return true
}
return false
}
private fun hideSlideUpOverlay() {
_slideUpOverlay?.hide(false)
_slideUpOverlay = null
}
private fun showArtist(channel: Artist) {
setLoading(false)
_fragment.topBar?.onShown(channel)
val buttons = arrayListOf(Pair(R.drawable.ic_playlist_add) {
})
_fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.contentUrl ?: return@launch)
withContext(Dispatchers.Main) {
buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData(
"", SearchType.VIDEO
)
)
})
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
}
_buttonSubscribe.visibility = GONE;
_buttonSubscriptionSettings.visibility =
if (_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE
_textChannel.text = channel.name
_textChannelSub.text = "${channel.countTracks} songs, ${channel.countAlbums} albums";
var supportsPlaylists = false;
val playlistPosition = 1
// keep the current tab selected
if (_viewPager.currentItem >= playlistPosition) {
_viewPager.setCurrentItem(_viewPager.currentItem + 1, false)
}
(_viewPager.adapter as ArtistViewPagerAdapter).insert(
playlistPosition,
ArtistTab.ALBUMS
)
// sets the channel for each tab
for (fragment in _fragment.childFragmentManager.fragments) {
(fragment as IArtistTabFragment).setArtist(channel)
}
(_viewPager.adapter as ArtistViewPagerAdapter).artist = channel
_viewPager.adapter!!.notifyDataSetChanged()
this.channel = channel
}
companion object {
private const val TAG = "LibraryArtistFragmentsView";
}
}
enum class ArtistTab {
SONGS, ALBUMS
}
class ArtistViewPagerAdapter(private val fragment: LibraryArtistFragment, fragmentManager: FragmentManager, lifecycle: Lifecycle) :
FragmentStateAdapter(fragmentManager, lifecycle) {
private val _supportedFragments = mutableMapOf(
ArtistTab.SONGS.ordinal to ArtistTab.SONGS
)
private val _tabs = arrayListOf(ArtistTab.SONGS, ArtistTab.ALBUMS)
var artist: Artist? = null
val onContentUrlClicked = Event2<String, ContentType>()
val onUrlClicked = Event1<String>()
val onContentClicked = Event2<IPlatformContent, Long>()
val onShortClicked = Event3<IPlatformContent, Long, Pair<IPager<IPlatformContent>, ArrayList<IPlatformContent>>?>()
val onChannelClicked = Event1<PlatformAuthorLink>()
val onAddToClicked = Event1<IPlatformContent>()
val onAddToQueueClicked = Event1<IPlatformContent>()
val onAddToWatchLaterClicked = Event1<IPlatformContent>()
val onLongPress = Event1<IPlatformContent>()
override fun getItemId(position: Int): Long {
return _tabs[position].ordinal.toLong()
}
override fun containsItem(itemId: Long): Boolean {
return _supportedFragments.containsKey(itemId.toInt())
}
override fun getItemCount(): Int {
return _supportedFragments.size
}
fun getTabPosition(tab: ArtistTab): Int {
return _tabs.indexOf(tab)
}
fun getTabNames(tab: TabLayout.Tab, position: Int) {
tab.text = _tabs[position].name
}
fun insert(position: Int, tab: ArtistTab) {
_supportedFragments[tab.ordinal] = tab
_tabs.add(position, tab)
notifyItemInserted(position)
}
fun remove(position: Int) {
_supportedFragments.remove(_tabs[position].ordinal)
_tabs.removeAt(position)
notifyItemRemoved(position)
}
override fun createFragment(position: Int): Fragment {
val fragment: Fragment
when (_tabs[position]) {
ArtistTab.SONGS -> {
fragment = ChannelContentsFragment(this.fragment).apply {
}
}
ArtistTab.ALBUMS -> {
fragment = ArtistAlbumsFragment(this.fragment).apply {
}
}
}
artist?.let { (fragment as IArtistTabFragment).setArtist(it) }
return fragment
}
}
interface IArtistTabFragment {
fun setArtist(artist: Artist);
}
class ChannelContentsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
var view: ArtistContentView? = null;
private var _lastArtist: Artist? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
view = ArtistContentView(frag, inflater);
_lastArtist?.let {
view?.setArtist(it);
}
return view;
}
override fun onDestroyView() {
view = null;
super.onDestroyView()
}
override fun setArtist(artist: Artist) {
view?.setArtist(artist);
_lastArtist = artist;
}
}
class ArtistContentView : FeedView<LibraryArtistFragment, IPlatformContent, IPlatformVideo, IPager<IPlatformContent>, TrackViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
protected var _artist: Artist? = null;
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater) {
}
fun setArtist(artist: Artist) {
this._artist = artist;
val tracks = artist.getAudioTracks();
if(tracks.getResults().isEmpty())
UIDialogs.appToast("No tracks found");
setPager(tracks);
}
override fun filterResults(results: List<IPlatformContent>): List<IPlatformVideo> {
return results.filter { it is IPlatformVideo }.map { it as IPlatformVideo };
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<IPlatformVideo>): InsertedViewAdapterWithLoader<TrackViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = TrackViewHolder(viewGroup);
holder.onClick.subscribe { c ->
val playlist = _artist?.toPlaylist();
if (playlist != null) {
val index = playlist.videos.indexOf(c);
if (index == -1)
return@subscribe;
StatePlayer.instance.setPlaylist(playlist, index, true);
}
};
holder.onOptions.subscribe {
if(it is IPlatformVideo)
UISlideOverlays.showVideoOptionsOverlay(it, _overlayContainer);
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
override fun updateSpanCount(){ }
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class ArtistAlbumsFragment(private val frag: LibraryArtistFragment) : Fragment(), IArtistTabFragment {
var view: ArtistAlbumsView? = null;
private var _lastArtist: Artist? = null;
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
view = ArtistAlbumsView(frag, inflater);
_lastArtist?.let {
view?.setArtist(it);
}
return view;
}
override fun onDestroyView() {
view = null;
super.onDestroyView()
}
override fun setArtist(artist: Artist) {
view?.setArtist(artist);
_lastArtist = artist;
}
}
class ArtistAlbumsView : FeedView<LibraryArtistFragment, Album, Album, IPager<Album>, AlbumTileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
constructor(fragment: LibraryArtistFragment, inflater: LayoutInflater) : super(fragment, inflater)
fun onShown() {
}
fun setArtist(artist: Artist) {
val initialAlbums = artist.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
setPager(AdhocPager({ listOf() }, initialAlbums));
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Album>): InsertedViewAdapterWithLoader<AlbumTileViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = AlbumTileViewHolder(viewGroup);
holder.setAutoSize(resources.displayMetrics.widthPixels / resources.displayMetrics.density)
holder.onClick.subscribe { c -> fragment.navigate<LibraryAlbumFragment>(c) };
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, AlbumTileViewHolder.getAutoSizeColumns(resources.displayMetrics.widthPixels / resources.displayMetrics.density))
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
}
@@ -0,0 +1,200 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryArtistsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _textMeta: TextView? = null;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryArtistsFragment().apply {}
}
class FragView : FeedView<LibraryArtistsFragment, Artist, Artist, IPager<Artist>, ArtistViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val libraryTypeHeader: LibraryTypeHeaderView;
constructor(fragment: LibraryArtistsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
libraryTypeHeader = LibraryTypeHeaderView(context);
libraryTypeHeader.setSelectedType(LibraryTypeHeaderView.SelectedType.Artists);
libraryTypeHeader.setMetadata("");
libraryTypeHeader.onSelectedChanged.subscribe {
when(it) {
LibraryTypeHeaderView.SelectedType.Albums -> fragment.navigate<LibraryAlbumsFragment>();
else -> {}
}
}
_toolbarContentView.addView(libraryTypeHeader);
disableRefreshLayout();
}
fun onShown() {
reload();
}
override fun reload() {
try {
setLoading(true);
val intialArtists = StateLibrary.instance.getArtists(ArtistOrdering.Alphabethic);
Logger.i(TAG, "Initial album count: " + intialArtists.size);
libraryTypeHeader.setMetadata("${intialArtists.size} artists");
setPager(AdhocPager<Artist>({ listOf(); }, intialArtists));
}
finally {
setLoading(false);
}
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<Artist>): InsertedViewAdapterWithLoader<ArtistViewHolder> {
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = ArtistViewHolder(viewGroup);
holder.onClick.subscribe { c ->
fragment.navigate<LibraryArtistFragment>(c)
};
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryArtistsFragmentsView";
}
}
class ArtistViewHolder(private val _viewGroup: ViewGroup) : AnyAdapter.AnyViewHolder<Artist>(
LayoutInflater.from(_viewGroup.context).inflate(R.layout.list_artist,
_viewGroup, false)) {
val onClick = Event1<Artist>();
protected var _artist: Artist? = null;
//protected val _imageThumbnail: ImageView
protected val _textName: TextView
protected val _textMetadata: TextView
init {
//_imageThumbnail = _view.findViewById(R.id.image_thumbnail);
_textName = _view.findViewById(R.id.text_name);
_textMetadata = _view.findViewById(R.id.text_metadata);
_view.setOnClickListener { _artist?.let { onClick.emit(it) } };
}
override fun bind(artist: Artist) {
_artist = artist;
/*
_imageThumbnail?.let {
if (artist.thumbnail != null)
Glide.with(it)
.load(artist.thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(it)
else
Glide.with(it).load(R.drawable.placeholder_channel_thumbnail).into(it);
};
*/
_textName.text = artist.name;
val metaComps = listOf(
artist.countTracks?.let { "${it} tracks" },
artist.countAlbums?.let { "${it} albums" }
).filterNotNull();
_textMetadata.text = metaComps.joinToString(", ");
}
}
}
@@ -0,0 +1,234 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.topbar.FilesTopBarFragment
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.NoResultsView
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
class LibraryFilesFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown(parameter);
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryFilesFragment().apply {}
}
class FragView : FeedView<LibraryFilesFragment, FileEntry, FileEntry, IPager<FileEntry>, FileViewHolder> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
val navStack = mutableListOf<FileStack>()
var buttonUp: BigButton? = null;
var buttonAdd: BigButton? = null;
private var root: FileEntry? = null;
constructor(fragment: LibraryFilesFragment, inflater: LayoutInflater) : super(fragment, inflater) {
}
fun onShown(parameter: Any? = null) {
this.root = if(parameter is FileEntry) parameter else null;
loadTop();
}
fun loadTop() {
var initialDirectories = listOf<FileEntry>();
if(root == null) {
initialDirectories = StateLibrary.instance.getFileDirectories();
if (initialDirectories.size == 0) {
setEmptyPager(true);
setPager(EmptyPager());
buttonAdd?.let {
it.isVisible = false;
}
buttonUp?.let {
it.isVisible = false;
}
return;
} else
setEmptyPager(false);
}
else {
buttonAdd?.let {
it.isVisible = false;
}
buttonUp?.let {
it.isVisible = false;
}
initialDirectories = root?.getSubFiles() ?: listOf();
}
navStack.clear();
val entry = FileStack("", initialDirectories);
navStack.add(entry);
openDirectory(navStack.last());
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
it.setUpNavigate(null);
it.setTitle(entry);
}
}
}
fun leaveDirectory() {
if(navStack.size > 1) {
navStack.removeLast();
openDirectory(navStack.last());
}
else {}
}
fun openDirectory(stack: FileStack, addToStack: Boolean = false) {
if(addToStack)
navStack.add(stack);
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
it.setTitle(stack);
}
}
buttonAdd?.let {
it.isVisible = navStack.size < 2
}
buttonUp?.let {
it.isVisible = navStack.size > 1;
}
setPager(AdhocPager<FileEntry>({ listOf(); }, stack.files));
setLoading(false);
fragment.topBar?.let {
if(it is FilesTopBarFragment) {
if(navStack.size > 1)
it.setUpNavigate{
leaveDirectory();
};
else it.setUpNavigate(null);
it.setTitle(stack);
}
}
}
fun setBack() {
fragment.topBar?.view
}
override fun getEmptyPagerView(): View? {
return NoResultsView(context, "No Directories Added",
"To see files in Grayjay you have to add directories to view",
R.drawable.ic_library, listOf(
BigButton(context, "Add Directory", "Select a directory to add", R.drawable.ic_add, {
StateLibrary.instance.addFileDirectory({
loadTop();
}, true);
})
))
}
override fun createAdapter(recyclerResults: RecyclerView, context: Context, dataset: ArrayList<FileEntry>): InsertedViewAdapterWithLoader<FileViewHolder> {
/*
val buttonUp = BigButton(fragment.requireContext(), "Go up", "Go up a directory", R.drawable.ic_move_up) {
if(navStack.size > 1)
leaveDirectory();
}
val buttonAdd = BigButton(fragment.requireContext(), "Add Directory", "Select a directory to add", R.drawable.ic_add) {
StateLibrary.instance.addFileDirectory {
loadTop();
};
}
*/
//this.buttonUp = buttonUp;
//this.buttonAdd = buttonAdd;
return InsertedViewAdapterWithLoader(context, arrayListOf(), arrayListOf(),
childCountGetter = { dataset.size },
childViewHolderBinder = { viewHolder, position -> viewHolder.bind(dataset[position]); },
childViewHolderFactory = { viewGroup, _ ->
val holder = FileViewHolder(viewGroup);
holder.onClick.subscribe { c ->
if (c != null) {
if(c.isDirectory) {
openDirectory(FileStack(c.path, c.getSubFiles()), true);
} else {
fragment.navigate<VideoDetailFragment>(c.path)
}
}
};
holder.onDelete.subscribe { c ->
if(c != null) {
StateLibrary.instance.deleteFileDirectory(c.path);
loadTop();
}
}
return@InsertedViewAdapterWithLoader holder;
}
);
}
override fun updateSpanCount(){ }
override fun createLayoutManager(recyclerResults: RecyclerView, context: Context): GridLayoutManager {
val glmResults = GridLayoutManager(context, 1)
_swipeRefresh.layoutParams = (_swipeRefresh.layoutParams as MarginLayoutParams?)?.apply {
rightMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
8.0f,
context.resources.displayMetrics
).toInt()
}
return glmResults
}
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
class FileStack(
val path: String,
val files: List<FileEntry>
)
}
@@ -0,0 +1,297 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.MediaStore
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.dp
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithTop
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.buttons.BigButton
class LibraryFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
private var allowedMusic = false;
private var allowedVideo = false;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this, allowedMusic, allowedVideo);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
requestPermissionMusic();
requestPermissionVideo();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
fun setPermissionResultAudio(access: Boolean) {
allowedMusic = access;
view?.setMusicPermissions(access);
StateApp.instance.hasMediaStoreAudioPermission = (access);
}
fun setPermissionResultVideo(access: Boolean) {
allowedVideo = access;
view?.setVideoPermissions(access);
StateApp.instance.hasMediaStoreVideoPermission = (access);
}
fun requestPermissionMusic() {
when {
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_GRANTED -> {
setPermissionResultAudio(true);
}
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_AUDIO) -> {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library,
"Music permissions", "We require permissions to see your on-device music, denying this will hide the option to see local music.", null, 1,
UIDialogs.Action("Ok", {
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE));
}
else -> {
permissionReqAudio.launch(android.Manifest.permission.READ_MEDIA_AUDIO);
}
}
}
fun requestPermissionVideo() {
when {
ContextCompat.checkSelfPermission(requireContext(), android.Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED -> {
setPermissionResultVideo(true);
}
ActivityCompat.shouldShowRequestPermissionRationale(requireActivity(), android.Manifest.permission.READ_MEDIA_VIDEO) -> {
UIDialogs.showDialog(requireContext(), R.drawable.ic_library, false,
"Videos permissions", "We require permissions to see your on-device videos, denying this will hide the option to see local videos.", null, 1,
UIDialogs.Action("Ok", {
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}, UIDialogs.ActionStyle.PRIMARY),
UIDialogs.Action("Cancel", {
}, UIDialogs.ActionStyle.NONE));
}
else -> {
permissionReqVideo.launch(android.Manifest.permission.READ_MEDIA_VIDEO);
}
}
}
val permissionReqAudio = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
setPermissionResultAudio(isGranted);
});
val permissionReqVideo = registerForActivityResult(ActivityResultContracts.RequestPermission(), { isGranted ->
setPermissionResultVideo(isGranted);
});
companion object {
fun newInstance() = LibraryFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: LibraryFragment;
var sectionArtists: LibrarySection;
var sectionAlbums: LibrarySection;
var sectionVideos: LibrarySection;
var sectionFiles: LibrarySection;
//var buttonFiles: BigButton;
val recycler: RecyclerView;
val adapterFiles: AnyInsertedAdapterView<FileEntry, FileViewHolder>;
//var metaInfo: TextView;
var allowMusic: Boolean = false;
var allowVideo: Boolean = false;
constructor(fragment: LibraryFragment, allowMusic: Boolean?, allowVideo: Boolean?) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library, this);
this.fragment = fragment;
recycler = findViewById(R.id.recycler);
sectionArtists = LibrarySection(context)//findViewById<LibrarySection>(R.id.section_artists);
sectionArtists.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 140.dp(resources)).apply {
this.setMargins(0,10.dp(resources), 0, 0);
}
sectionAlbums = LibrarySection(context)//findViewById(R.id.section_albums);
sectionAlbums.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 185.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionVideos = LibrarySection(context)//findViewById(R.id.section_videos);
sectionVideos.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 170.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionFiles = LibrarySection(context)//findViewById(R.id.section_videos);
sectionFiles.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 40.dp(resources)).apply {
this.setMargins(0,0, 0, 0);
}
sectionFiles.setSection("Directories") {
StateLibrary.instance.addFileDirectory({
reloadFiles();
}, true)
}
sectionFiles.setNavIcon(R.drawable.ic_add);
//buttonFiles = findViewById<BigButton>(R.id.button_files);
//metaInfo = findViewById(R.id.meta_info);
this.allowMusic = allowMusic ?: false;
this.allowVideo = allowVideo ?: false;
sectionArtists.setSection("Artists", {
if(this.allowMusic)
fragment.navigate<LibraryArtistsFragment>();
else
fragment.requestPermissionMusic();
});
val adapterArtists = sectionArtists.getAnyAdapter<Artist, ArtistTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryArtistFragment>(it);
}
});
val artists = StateLibrary.instance.getArtists(ArtistOrdering.TrackCount);
adapterArtists.setData(artists);
sectionAlbums.setSection("Albums", {
if(this.allowMusic)
fragment.navigate<LibraryAlbumsFragment>();
else
fragment.requestPermissionMusic();
});
val adapterAlbums = sectionAlbums.getAnyAdapter<Album, AlbumTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryAlbumFragment>(it);
}
});
val albums = StateLibrary.instance.getAlbums();
adapterAlbums.setData(albums);
sectionVideos.setSection("Videos", {
if(this.allowVideo)
fragment.navigate<LibraryVideosFragment>();
else
fragment.requestPermissionVideo();
});
val adapterVideos = sectionVideos.getAnyAdapter<IPlatformVideo, LocalVideoTileViewHolder>({
it.onClick.subscribe {
if(it != null)
fragment.navigate<VideoDetailFragment>(it);
}
});
val videos = StateLibrary.instance.getRecentVideos(null, 20);
adapterVideos.setData(videos);
adapterFiles = recycler.asAnyWithViews<FileEntry, FileViewHolder>(
arrayListOf(
sectionArtists,
sectionAlbums,
sectionVideos,
sectionFiles
),
arrayListOf(View(context).apply { this.layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 20.dp(resources)) }),
RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryFilesFragment>(it);
}
it.onDelete.subscribe {
if(it != null) {
StateLibrary.instance.deleteFileDirectory(it.path);
reloadFiles();
}
}
}
);
reloadFiles();
/*
buttonFiles.onClick.subscribe {
fragment.navigate<LibraryFilesFragment>()
} */
//buttonFiles.setButtonEnabled(false);
setMusicPermissions(allowMusic ?: false);
setVideoPermissions(allowVideo ?: false);
}
fun reloadFiles() {
val files = StateLibrary.instance.getFileDirectories();
adapterFiles.setData(files);
}
fun setMusicPermissions(access: Boolean) {
allowMusic = access;
sectionAlbums.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
sectionArtists.setContentEmptyMessage(R.drawable.ic_library, "No mediastore permissions");
//buttonArtists.setButtonEnabled(access);
//metaInfo.text = listOf(
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
//).filterNotNull().joinToString("\n");
}
fun setVideoPermissions(access: Boolean) {
allowVideo = access;
sectionVideos.setContentEmptyMessage(R.drawable.ic_library, "No video permissions");
//metaInfo.text = listOf(
// if(!allowMusic) "You did not give access to local music, so these options are disabled" else null,
// if(!allowVideo) "You did not give access to local videos, so these options are disabled" else null
//).filterNotNull().joinToString("\n");
}
fun onShown() {
}
}
}
@@ -0,0 +1,233 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.Artist
import com.futo.platformplayer.states.ArtistOrdering
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.AnyInsertedAdapterView
import com.futo.platformplayer.views.AnyInsertedAdapterView.Companion.asAnyWithViews
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.LibrarySection
import com.futo.platformplayer.views.LibraryTypeHeaderView
import com.futo.platformplayer.views.LibraryTypeHeaderView.SelectedType
import com.futo.platformplayer.views.PillV2
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.AlbumTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.ArtistTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.FileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.LocalVideoTileViewHolder
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.adapters.viewholders.TrackViewHolder
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibrarySearchFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibrarySearchFragment().apply {}
}
class FragView: ConstraintLayout {
val fragment: LibrarySearchFragment;
val pillArtist: PillV2;
val pillAlbums: PillV2;
val pillSongs: PillV2;
val pills: List<PillV2>;
val textMetadata: TextView;
val recycler: RecyclerView;
val adapterArtists: AnyAdapterView<Artist, LibraryArtistsFragment.ArtistViewHolder>;
val adapterSongs: AnyAdapterView<IPlatformContent, TrackViewHolder>;
val adapterAlbums: AnyAdapterView<Album, LibraryAlbumsFragment.AlbumViewHolder>;
constructor(fragment: LibrarySearchFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.fragview_library_search, this);
this.fragment = fragment;
pillArtist = findViewById(R.id.pill_artist);
pillAlbums = findViewById(R.id.pill_albums);
pillSongs = findViewById(R.id.pill_songs);
pills = listOf(pillArtist, pillAlbums, pillSongs);
textMetadata = findViewById(R.id.text_metadata);
pillArtist.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillArtist.setIsEnabled(true);
loadArtists();
}
pillAlbums.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillAlbums.setIsEnabled(true);
loadAlbums();
}
pillSongs.onClick.subscribe {
pills.forEach { it.setIsEnabled(false) };
pillSongs.setIsEnabled(true);
loadSongs();
}
recycler = findViewById(R.id.recycler);
adapterArtists = recycler.asAny<Artist, LibraryArtistsFragment.ArtistViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryArtistFragment>(it);
}
});
adapterAlbums = recycler.asAny<Album, LibraryAlbumsFragment.AlbumViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null)
fragment.navigate<LibraryAlbumFragment>(it);
}
});
adapterSongs = recycler.asAny<IPlatformContent, TrackViewHolder>(RecyclerView.VERTICAL, false, {
it.onClick.subscribe {
if(it != null && it is IPlatformVideo)
fragment.navigate<VideoDetailFragment>(it);
}
});
fragment.topBar?.let {
if(it is SearchTopBarFragment) {
it.onSearch.subscribe {
search(it);
}
}
}
pillArtist.setIsEnabled(true);
loadArtists();
}
fun loadArtists(){
recycler.adapter = adapterArtists.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun loadAlbums() {
recycler.adapter = adapterAlbums.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun loadSongs() {
recycler.adapter = adapterSongs.adapter.adapter;
fragment.topBar?.let {
if(it is SearchTopBarFragment)
search(it.getSearchText());
}
}
fun search(str: String) {
if(recycler.adapter == adapterArtists.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchArtists(str)
else listOf();
adapterArtists.setData(data);
textMetadata.text = "${data.size} artists";
}
else if(recycler.adapter == adapterAlbums.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchAlbums(str)
else listOf();
adapterAlbums.setData(data);
textMetadata.text = "${data.size} albums";
}
else if(recycler.adapter == adapterSongs.adapter.adapter) {
val data = if(!str.isNullOrBlank())
StateLibrary.instance.searchTracks(str)
else listOf();
adapterSongs.setData(data);
textMetadata.text = "${data.size} songs";
}
}
fun onShown() {
fragment.topBar?.let {
if(it is SearchTopBarFragment)
it.focus();
}
}
}
}
@@ -0,0 +1,170 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout.GONE
import android.widget.LinearLayout.VISIBLE
import android.widget.Spinner
import android.widget.TextView
import androidx.core.view.allViews
import androidx.core.widget.addTextChangedListener
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.UISlideOverlays.Companion.showOrderOverlay
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.Album
import com.futo.platformplayer.states.StateLibrary
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.ToggleBar
import com.futo.platformplayer.views.adapters.AnyAdapter
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
import com.futo.platformplayer.views.adapters.viewholders.SelectablePlaylist
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator
class LibraryVideosFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var _toggleBuckets = StateLibrary.instance.getVideoBucketNames().map { it.name }.toMutableList();
var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = FragView(this, inflater);
this.view = view;
return view;
}
override fun onShown(parameter: Any?, isBack: Boolean) {
super.onShown(parameter, isBack)
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
view?.onShown();
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = LibraryVideosFragment().apply {}
}
class FragView : ContentFeedView<LibraryVideosFragment> {
override val feedStyle: FeedStyle = FeedStyle.THUMBNAIL; //R.layout.list_creator;
private var _toggleBar: ToggleBar? = null;
constructor(fragment: LibraryVideosFragment, inflater: LayoutInflater) : super(fragment, inflater) {
initializeToolbarContent();
disableRefreshLayout();
}
fun onShown() {
val initialAlbums = StateLibrary.instance.getAlbums();
Logger.i(TAG, "Initial album count: " + initialAlbums.size);
val buckets = StateLibrary.instance.getVideoBucketNames();
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
}
private val _filterLock = Object();
fun initializeToolbarContent() {
if(_toolbarContentView.allViews.any { it is ToggleBar })
_toolbarContentView.removeView(_toolbarContentView.allViews.find { it is ToggleBar });
_toggleBar = ToggleBar(context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
synchronized(_filterLock) {
var buttonsPlugins: List<ToggleBar.Toggle> = listOf()
buttonsPlugins =
(StateLibrary.instance.getVideoBucketNames()
.map { bucket ->
ToggleBar.Toggle(bucket.name, null, fragment._toggleBuckets.contains(bucket.name), { view, active ->
var dontSwap = false;
if (!active) {
if (fragment._toggleBuckets.contains(bucket.name))
fragment._toggleBuckets.remove(bucket.name);
} else {
if (!fragment._toggleBuckets.contains(bucket.name)) {
val enabledClients = StatePlatform.instance.getEnabledClients();
val availableAfterDisable = enabledClients.count { !fragment._toggleBuckets.contains(it.id) && it.id != bucket.name };
if(availableAfterDisable > 0)
fragment._toggleBuckets.add(bucket.name);
else {
UIDialogs.appToast("Select atleast 1 bucket");
dontSwap = true;
}
}
}
if(!dontSwap)
reloadForFilters();
else {
view.setToggle(active);
}
}, { view, views, enabled ->
val toDisable = views.filter { it != view && it.tag == "plugins" };
if(!view.isActive)
view.handleClick();
for(tag in toDisable) {
if(tag.isActive)
tag.handleClick();
}
}).withTag("plugins")
})
val buttons = (buttonsPlugins)
.sortedBy { it.name }.toTypedArray()
_toggleBar?.setToggles(*buttons);
}
_toolbarContentView.addView(_toggleBar, 0);
}
fun reloadForFilters() {
setPager(StateLibrary.instance.getVideos(fragment._toggleBuckets));
}
override fun updateSpanCount(){ }
companion object {
private const val TAG = "LibraryAlbumsFragmentsView";
}
}
}
@@ -0,0 +1,52 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.Spinner
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.futo.platformplayer.R
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
class RecyclerFragment : MainFragment(){
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: View? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = RecyclerFragment.View(inflater.context);
view = newView;
return newView;
}
override fun onDestroyMainView() {
view = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = RecyclerFragment().apply {}
}
class View: ConstraintLayout {
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.fragview_filter_recycler, this);
}
}
}
@@ -0,0 +1,184 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.app.NotificationManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.SettingsDev
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.assume
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 SettingsFragment : MainFragment() {
override val isMainView : Boolean = true;
override val isTab: Boolean = true;
override val hasBottomBar: Boolean get() = true;
private var view: FragView? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): android.view.View {
val newView = FragView(this);
view = newView;
return newView;
}
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
super.onShownWithView(parameter, isBack);
_currentView = view;
view?.onShown(parameter);
}
override fun onHide() {
super.onHide();
onClosed.emit();
}
override fun onDestroyMainView() {
view = null;
_currentView = null;
super.onDestroyMainView();
}
companion object {
fun newInstance() = SettingsFragment().apply {}
val onClosed = Event0();
private var _currentView: FragView? = null;
val currentView: FragView?
get() = _currentView;
}
class FragView: ConstraintLayout {
val fragment: SettingsFragment;
private val _form: FieldForm;
private val _buttonBack: ImageButton;
private val _loaderView: LoaderView;
private val _devSets: LinearLayout;
private val _buttonDev: MaterialButton;
private var _isFinished = false;
lateinit var overlay: FrameLayout;
val notifPermission = "android.permission.POST_NOTIFICATIONS";
constructor(fragment: SettingsFragment) : super(fragment.requireContext()) {
inflate(context, R.layout.activity_settings, this);
this.fragment = fragment;
val activity = fragment.activity;
findViewById<LinearLayout>(R.id.container_topbar).isVisible = false;
_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(context, Settings.instance.language.getAppLanguageLocaleString());
}
if(field.descriptor?.id == "background_update" && activity is MainActivity) {
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
val notifManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
if(!notifManager.areNotificationsEnabled()) {
UIDialogs.toast(context, "Notifications aren't enabled");
activity.requestNotificationPermissions("Notifications need to be enabled for background updating to function")
}
}
}
};
_buttonBack.setOnClickListener {
//finish();
}
_buttonDev.setOnClickListener {
//startActivity(Intent(this, DeveloperActivity::class.java));
fragment.navigate<DeveloperFragment>(null, true);
}
//_lastActivity = this;
reloadSettings();
}
var isFirstLoad = true;
fun reloadSettings() {
val firstLoad = isFirstLoad;
isFirstLoad = false;
_form.setSearchVisible(false);
_loaderView.start();
_form.fromObject(fragment.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(context, fragment.getString(R.string.you_are_now_in_developer_mode));
}
};
/*
if(firstLoad) {
val query = intent.getStringExtra("query");
if(!query.isNullOrEmpty()) {
_form.setSearchQuery(query);
}
}*/
};
}
fun onShown(str: Any? = null) {
updateDevMode();
if(str is String)
_form.setSearchQuery(str);
}
fun updateDevMode() {
if(SettingsDev.instance.developerMode)
_devSets.visibility = View.VISIBLE;
else
_devSets.visibility = View.GONE;
}
}
}
@@ -5,6 +5,7 @@ import android.content.Intent
import android.graphics.drawable.Animatable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
@@ -214,6 +215,16 @@ class ShortView : FrameLayout {
}
}
player.onPlayChanged.subscribe {
if (it) {
Logger.i(TAG, "Keep screen on set because isPlaying")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
Logger.i(TAG, "Keep screen on cleared because not isPlaying")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
onPlayingToggled.subscribe { playing ->
if (playing) {
playPauseIcon.setImageResource(R.drawable.ic_play)
@@ -7,6 +7,7 @@ import android.view.LayoutInflater
import android.view.SoundEffectConstants
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
@@ -309,6 +310,12 @@ class ShortsFragment : MainFragment() {
customViewAdapter?.previousShownView?.stop()
}
override fun onDestroyMainView() {
super.onDestroyMainView()
Logger.i(TAG, "Keep screen on cleared because onDestroyMainView fragment")
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
companion object {
private const val TAG = "ShortsFragment"
@@ -1,5 +1,8 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
@@ -32,9 +35,11 @@ import com.futo.platformplayer.views.buttons.BigButton
import com.futo.platformplayer.views.buttons.BigButtonGroup
import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.sources.SourceHeaderView
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class SourceDetailFragment : MainFragment() {
override val isMainView : Boolean = true;
@@ -415,11 +420,39 @@ class SourceDetailFragment : MainFragment() {
}
val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Reset Settings", "Resets the settings to their default (deleting existing settings)", R.drawable.ic_refresh) {
_config?.let {
StatePlugins.instance.setPluginSettings(it.id, hashMapOf());
loadConfig(it)
}
},
BigButton(c, "Share Settings", "Shares the settings of this plugin as json, mostly used for bug reporting", R.drawable.ic_code) {
val structure = Json { this.prettyPrint = true; this.prettyPrintIndent = " " }
.encodeToString(_settings);
fragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND;
putExtra(Intent.EXTRA_TEXT, structure);
type = "text/plain";
}, null));
/*
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Settings Json", structure)
clipboard.setPrimaryClip(clip)
UIDialogs.toast(context, "Copied", false);
*/
}.apply {
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
};
} ,
/*
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
}.apply {
this.alpha = 0.5f;
},
},*/
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
val embeddedConfig = StatePlugins.instance.getEmbeddedPluginConfigFromID(context, config.id);
@@ -24,7 +24,6 @@ import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.casting.StateCasting
@@ -401,9 +400,10 @@ class VideoDetailFragment() : MainFragment() {
_loadUrlOnCreate?.let { _viewDetail?.setVideo(it.url, it.timeSeconds, it.playWhenReady) };
maximizeVideoDetail();
/*
SettingsActivity.settingsActivityClosed.subscribe(this) {
updateOrientation()
}
} */
StatePlayer.instance.onRotationLockChanged.subscribe(this) {
updateOrientation()
@@ -547,7 +547,7 @@ class VideoDetailFragment() : MainFragment() {
super.onDestroyMainView();
Logger.v(TAG, "onDestroyMainView");
SettingsActivity.settingsActivityClosed.remove(this)
//SettingsActivity.settingsActivityClosed.remove(this)
StatePlayer.instance.onRotationLockChanged.remove(this)
_landscapeOrientationListener?.disableListener()
@@ -244,6 +244,7 @@ class VideoDetailView : ConstraintLayout {
private val _buttonSubscribe: SubscribeButton;
private val _buttonPins: RoundButtonGroup;
private var _loaderGameVisible = false
//private val _buttonMore: RoundButton;
var preventPictureInPicture: Boolean = false
@@ -261,7 +262,6 @@ class VideoDetailView : ConstraintLayout {
private val _textSkip: TextView;
private val _textResume: TextView;
private val _layoutResume: LinearLayout;
private var _jobHideResume: Job? = null;
private val _layoutPlayerContainer: TouchInterceptFrameLayout;
private val _layoutChangeBottomSection: LinearLayout;
@@ -336,7 +336,7 @@ class VideoDetailView : ConstraintLayout {
!StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() &&
!isAudioOnlyUserAction &&
isPlaying
(isPlaying || _loaderGameVisible)
val onShouldEnterPictureInPictureChanged = Event0();
@@ -357,6 +357,7 @@ class VideoDetailView : ConstraintLayout {
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds
);
private var _subtitleLanguage: String? = null
@androidx.annotation.OptIn(UnstableApi::class)
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
@@ -548,6 +549,16 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore;
updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean ->
_loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit()
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_channelButton.setOnClickListener {
if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener
@@ -576,9 +587,8 @@ class VideoDetailView : ConstraintLayout {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
val ad = StateCasting.instance.activeDevice
if (ad != null) {
ad.seekVideo(chapter.timeEnd)
if (StateCasting.instance.activeDevice != null) {
StateCasting.instance.videoSeekTo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
@@ -873,11 +883,6 @@ class VideoDetailView : ConstraintLayout {
_layoutResume.setOnClickListener {
handleSeek(_historicalPosition * 1000);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
_layoutResume.visibility = View.GONE;
};
@@ -886,7 +891,7 @@ class VideoDetailView : ConstraintLayout {
if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
ad.seekVideo(currentChapter.timeEnd);
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
@@ -1256,10 +1261,6 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onCloseReceived.remove(this);
MediaControlReceiver.onBackgroundReceived.remove(this);
MediaControlReceiver.onSeekToReceived.remove(this);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
}
//Video Setters
@@ -1781,26 +1782,7 @@ class VideoDetailView : ConstraintLayout {
TAG,
"Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"
);
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(
_historicalPosition - lastPositionMilliseconds / 1000
) > 5.0
) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
}
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
}
}
@@ -1846,6 +1828,35 @@ class VideoDetailView : ConstraintLayout {
_taskLoadRecommendations.run(videoDetail.url)
}
}
private fun shouldShowResume(positionMs: Long): Boolean {
if (_loaderGameVisible) return false
val v = video ?: return false
val resumeS = _historicalPosition
val durS = v.duration
if (_overlay_loading.visibility == View.VISIBLE) return false
if (resumeS <= 60) return false
if (durS - resumeS <= 5) return false
val posMs = positionMs
val resumeMs = resumeS * 1000
val durMs = durS * 1000L
val inFirstFewSeconds = posMs < 8_000
val notYetReachedResume = (resumeMs - posMs) > 5_000
return inFirstFewSeconds && notYetReachedResume && durMs > 0
}
private fun updateResumeVisibilityFor(positionMs: Long) {
val visible = shouldShowResume(positionMs)
if (visible) {
_layoutResume.visibility = View.VISIBLE
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"
} else {
_layoutResume.visibility = View.GONE
_textResume.text = ""
}
}
fun loadVODChat(video: IPlatformVideoDetails) {
_liveChat?.stop();
_container_content_liveChat.cancel();
@@ -1965,7 +1976,7 @@ class VideoDetailView : ConstraintLayout {
try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
val subtitleSource = _lastSubtitleSource ?: (if (Settings.instance.playback.stickySubtitles) _player.getPreferredSubtitleSource(video, _subtitleLanguage) else null) ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) {
@@ -2368,11 +2379,11 @@ class VideoDetailView : ConstraintLayout {
?.distinct()
?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
R.string.quality), null, true,
R.string.quality), null, true,
qualityPlaybackSpeedTitle,
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
@@ -2393,7 +2404,7 @@ class VideoDetailView : ConstraintLayout {
val newPlaybackSpeed = playbackSpeedString.toDouble();
if (_isCasting) {
val ad = StateCasting.instance.activeDevice ?: return@subscribe
if (!ad.canSetSpeed) {
if (!ad.canSetSpeed()) {
return@subscribe
}
@@ -2649,6 +2660,7 @@ class VideoDetailView : ConstraintLayout {
}
}
_lastSubtitleSource = toSet;
_subtitleLanguage = toSet?.language
}
private fun handleUnavailableVideo(msg: String? = null) {
@@ -2806,6 +2818,8 @@ class VideoDetailView : ConstraintLayout {
_overlay_loading.visibility = View.GONE;
(_overlay_loading_spinner.drawable as Animatable?)?.stop()
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
//UI Actions
@@ -3039,9 +3053,9 @@ class VideoDetailView : ConstraintLayout {
}
val playpauseAction = if(_player.playing)
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 5));
RemoteAction(Icon.createWithResource(context, R.drawable.ic_pause_notif), context.getString(R.string.pause), context.getString(R.string.pauses_the_video), MediaControlReceiver.getPauseIntent(context, 2));
else
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 6));
RemoteAction(Icon.createWithResource(context, R.drawable.ic_play_notif), context.getString(R.string.play), context.getString(R.string.resumes_the_video), MediaControlReceiver.getPlayIntent(context, 1));
val toBackgroundAction = RemoteAction(Icon.createWithResource(context, R.drawable.ic_screen_share), context.getString(R.string.background), context.getString(R.string.background_switch_audio), MediaControlReceiver.getToBackgroundIntent(context, 7));
@@ -3096,6 +3110,8 @@ class VideoDetailView : ConstraintLayout {
handleSeek(55000);
}
}
updateResumeVisibilityFor(positionMilliseconds)
}
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
@@ -20,6 +20,7 @@ import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume
import com.futo.platformplayer.downloads.VideoDownload
import com.futo.platformplayer.images.GlideHelper
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StatePlaylists
@@ -194,22 +195,35 @@ abstract class VideoListEditorView : LinearLayout {
_textMetadata.text = parts.joinToString("");
}
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean) {
if (videos != null && videos.isNotEmpty()) {
val video = videos.first();
protected fun setVideos(videos: List<IPlatformVideo>?, canEdit: Boolean, thumbnail: String? = null) {
if(thumbnail != null) {
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.load(thumbnail)
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
};
} else {
_textMetadata.text = "0 " + context.getString(R.string.videos);
Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail)
.into(_imagePlaylistThumbnail)
}
}
else {
if (videos != null && videos.isNotEmpty()) {
val video = videos.first();
_imagePlaylistThumbnail.let {
Glide.with(it)
.load(video.thumbnails.getHQThumbnail())
.placeholder(R.drawable.placeholder_video_thumbnail)
.crossfade()
.into(it);
};
} else {
Glide.with(_imagePlaylistThumbnail)
.load(R.drawable.placeholder_video_thumbnail)
.into(_imagePlaylistThumbnail)
}
}
if(videos == null || videos.isEmpty())
_textMetadata.text = "0 " + context.getString(R.string.videos);
_loadedVideos = videos;
_loadedVideosCanEdit = canEdit;
_videoListEditorView.setVideos(videos, canEdit);
@@ -0,0 +1,129 @@
package com.futo.platformplayer.fragment.mainactivity.topbar
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFilesFragment
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.FileEntry
import com.futo.platformplayer.views.casting.CastButton
import com.futo.polycentric.core.PolycentricProfile
class FilesTopBarFragment : TopFragment() {
private var _buttonBack: ImageButton? = null;
private var _buttonCast: CastButton? = null;
private var _textTitle: TextView? = null;
private var _menuItems: LinearLayout? = null;
private var _upHandle: (()->Unit)? = null;
override fun onShown(parameter: Any?) {
setTitle(parameter);
setMenuItems(listOf());
}
override fun onHide() {
}
fun setTitle(parameter: Any? = null) {
if(parameter is IPlatformChannel) {
_textTitle?.text = parameter.name;
} else if(parameter is PlatformAuthorLink) {
_textTitle?.text = parameter.name;
} else if (parameter is Playlist) {
_textTitle?.text = parameter.name;
} else if (parameter is String) {
_textTitle?.text = parameter;
} else if (parameter is IPlatformClient) {
_textTitle?.text = parameter.name;
} else if (parameter is PolycentricProfile) {
_textTitle?.text = parameter.systemState.username;
} else if(parameter is FileEntry) {
val treePrefix = "content://com.android.externalstorage.documents/tree/";
if(parameter.path.startsWith(treePrefix)) {
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
}
else if(parameter.path.isNullOrBlank())
_textTitle?.text = parameter.name;
else
_textTitle?.text = parameter.path;
}
else if(parameter is LibraryFilesFragment.FileStack) {
val treePrefix = "content://com.android.externalstorage.documents/tree/";
if(parameter.path.startsWith(treePrefix)) {
_textTitle?.text = parameter.path.substring(treePrefix.length - 1).replace("%3A", " ").replace("%2F", "/");
}
else
_textTitle?.text = parameter.path;
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_files_top_bar, container, false);
val buttonBack: ImageButton = view.findViewById(R.id.button_back);
_textTitle = view.findViewById(R.id.text_title);
_menuItems = view.findViewById(R.id.menu_buttons)
buttonBack.setOnClickListener {
if(_upHandle != null)
_upHandle?.invoke();
else
closeSegment();
};
_buttonBack = buttonBack;
return view;
}
fun setUpNavigate(handle: (()->Unit)? = null) {
_upHandle = handle;
_buttonBack?.setImageResource(if(handle == null) R.drawable.ic_back_nav else R.drawable.ic_arrow_up);
}
override fun onDestroyView() {
super.onDestroyView()
_buttonBack?.setOnClickListener(null);
_buttonBack = null;
_buttonCast?.cleanup();
_buttonCast = null;
_textTitle = null;
}
fun setMenuItems(items: List<Pair<Int, ()->Unit>>) {
_menuItems?.removeAllViews();
val dp4 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics).toInt();
val dp9 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 9f, resources.displayMetrics).toInt();
for(item in items) {
val compatImageItem = AppCompatImageView(requireContext());
compatImageItem.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT);
compatImageItem.setImageResource(item.first);
compatImageItem.setPadding(dp4, dp9, dp4, dp9);
compatImageItem.scaleType = ImageView.ScaleType.FIT_CENTER;
compatImageItem.setOnClickListener {
item.second.invoke();
};
_menuItems?.addView(compatImageItem);
}
}
companion object {
fun newInstance() = FilesTopBarFragment().apply { }
}
}
@@ -9,6 +9,8 @@ import android.widget.ImageView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fragment.mainactivity.main.CreatorsFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibraryFragment
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistFragment
import com.futo.platformplayer.fragment.mainactivity.main.PlaylistsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
@@ -46,6 +48,8 @@ class GeneralTopBarFragment : TopFragment() {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.CREATOR));
} else if (currentMain is PlaylistsFragment || currentMain is PlaylistFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.PLAYLIST));
} else if (currentMain is LibraryFragment) {
navigate<LibrarySearchFragment>();
} else {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO));
}
@@ -18,6 +18,7 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.fragment.mainactivity.main.LibrarySearchFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SuggestionsFragmentData
import com.futo.platformplayer.logging.Logger
@@ -112,7 +113,10 @@ class SearchTopBarFragment : TopFragment() {
}
fun clear() {
_editSearch?.text?.clear();
if (currentMain !is SuggestionsFragment) {
if(currentMain is LibrarySearchFragment) {
onSearch.emit("");
}
else if (currentMain !is SuggestionsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
} else {
onSearch.emit("");
@@ -190,6 +194,12 @@ class SearchTopBarFragment : TopFragment() {
_buttonFilter?.visibility = if (visible) View.VISIBLE else View.GONE;
}
fun getSearchText(): String {
return _editSearch?.let {
it.text.toString();
} ?: "";
}
private fun onDone() {
val editSearch = _editSearch
if (editSearch != null) {
@@ -19,6 +19,8 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSour
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
@@ -134,6 +136,62 @@ class VideoHelper {
return bestSource;
}
fun selectBestSubtitleSource(sources: Iterable<ISubtitleSource>, preferredLanguage: String?): ISubtitleSource? {
if (preferredLanguage.isNullOrBlank()) return null
val prefTag = normalizeTag(preferredLanguage)
val prefPrimary = primarySubtag(prefTag) ?: return null
var best: ISubtitleSource? = null
var bestKey: Quad<Int, Int, String, String>? = null
for (src in sources) {
val raw = src.language ?: continue
val tag = normalizeTag(raw)
val primary = primarySubtag(tag) ?: continue
val score = when {
tag.equals(prefTag, ignoreCase = true) -> 0
primary.equals(prefPrimary, ignoreCase = true) && findRegion(tag) == null -> 1
primary.equals(prefPrimary, ignoreCase = true) -> 2
else -> 3
}
if (score >= 3) continue
val key = Quad(score, src.name.length, tag.lowercase(), src.name)
if (bestKey == null || key < bestKey!!) {
bestKey = key
best = src
}
}
return best
}
private fun normalizeTag(tag: String): String = tag.trim().replace('_', '-')
private fun primarySubtag(tag: String): String? = tag.split('-').firstOrNull { it.isNotBlank() }?.lowercase()
private fun findRegion(tag: String): String? {
val parts = tag.split('-').drop(1) // skip primary language
for (p in parts) {
val isAlpha2 = p.length == 2 && p[0].isLetter() && p[1].isLetter()
val isNumeric3 = p.length == 3 && p.all { it.isDigit() }
if (isAlpha2 || isNumeric3) return p.uppercase()
}
return null
}
private data class Quad<A : Comparable<A>, B : Comparable<B>, C : Comparable<C>, D : Comparable<D>>(
val a: A, val b: B, val c: C, val d: D
) : Comparable<Quad<A, B, C, D>> {
override fun compareTo(other: Quad<A, B, C, D>): Int =
when {
a != other.a -> a.compareTo(other.a)
b != other.b -> b.compareTo(other.b)
c != other.c -> c.compareTo(other.c)
else -> d.compareTo(other.d)
}
}
@OptIn(UnstableApi::class)
fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> {
val urlToUse = videoSource.getVideoUrl();
@@ -29,7 +29,6 @@ class GlideHelper {
req.into(this);
}
fun RequestBuilder<Drawable>.crossfade(): RequestBuilder<Drawable> {
return this.transition(DrawableTransitionOptions.withCrossFade());
}
@@ -1,11 +1,14 @@
package com.futo.platformplayer.images;
import android.content.Context;
import android.os.Build;
import android.util.Log;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
import java.io.InputStream;
import java.nio.ByteBuffer;
@GlideModule
@@ -14,5 +17,8 @@ public class GrayjayAppGlideModule extends AppGlideModule {
public void registerComponents(Context context, Glide glide, Registry registry) {
Log.i("GrayjayAppGlideModule", "registerComponents called");
registry.prepend(String.class, ByteBuffer.class, new PolycentricModelLoader.Factory());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
registry.prepend(String.class, InputStream.class, new MediaStoreThumbnailLoader.InputStreamFactory());
}
}
}
@@ -0,0 +1,74 @@
package com.futo.platformplayer.images
import android.content.ContentResolver
import android.graphics.Point
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import com.bumptech.glide.load.Options
import com.bumptech.glide.load.data.LocalUriFetcher
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
import com.bumptech.glide.signature.ObjectKey
import com.futo.platformplayer.states.StateApp
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
@RequiresApi(Build.VERSION_CODES.Q)
class MediaStoreThumbnailLoader private constructor() : ModelLoader<String, InputStream> {
override fun handles(model: String): Boolean = isMediaStoreAudioUri(model)
private fun isMediaStoreAudioUri(uri: String): Boolean {
try {
val parsed = Uri.parse(uri);
return ContentResolver.SCHEME_CONTENT == parsed.scheme
&& MediaStore.AUTHORITY == parsed.authority
&& "audio" in parsed.pathSegments
}
catch(ex: MalformedURLException) {
return false;
}
}
override fun buildLoadData(model: String, width: Int, height: Int, options: Options): ModelLoader.LoadData<InputStream>? {
val diskCacheKey = ObjectKey(model)
val resolver = StateApp.instance.contextOrNull?.contentResolver ?: return null;
val fetcher = InputStreamFetcher(resolver, Uri.parse(model), width, height)
return ModelLoader.LoadData(diskCacheKey, fetcher)
}
class InputStreamFactory() : ModelLoaderFactory<String, InputStream> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<String, InputStream> = MediaStoreThumbnailLoader()
override fun teardown() {
// Do nothing.
}
}
private class InputStreamFetcher(resolver: ContentResolver, uri: Uri, private val width: Int, private val height: Int) : LocalUriFetcher<InputStream>(resolver, uri) {
override fun getDataClass(): Class<InputStream> = InputStream::class.java
@Throws(FileNotFoundException::class)
override fun loadResource(uri: Uri, contentResolver: ContentResolver): InputStream {
val optimalSizeOptions = Bundle(1)
optimalSizeOptions.putParcelable(ContentResolver.EXTRA_SIZE, Point(width, height))
return contentResolver.openTypedAssetFile(uri, "image/*", optimalSizeOptions, null)
?.createInputStream()
?: throw FileNotFoundException("FileDescriptor is null for: $uri")
}
@Throws(IOException::class)
override fun close(data: InputStream) {
data.close()
}
}
}
@@ -3,16 +3,9 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.casting.CastProtocolType
@kotlinx.serialization.Serializable
class CastingDeviceInfo {
var name: String;
var type: CastProtocolType;
var addresses: Array<String>;
var port: Int;
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
this.name = name;
this.type = type;
this.addresses = addresses;
this.port = port;
}
}
class CastingDeviceInfo(
var name: String,
var type: CastProtocolType,
var addresses: Array<String>,
var port: Int
)
@@ -0,0 +1,80 @@
package com.futo.platformplayer.polycentric
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import org.json.JSONObject
class ModerationsManager private constructor(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("polycentric_moderation", Context.MODE_PRIVATE)
private val _moderationLevels = MutableLiveData<Map<String, Int>>()
val moderationLevels: LiveData<Map<String, Int>> = _moderationLevels
init {
loadModerationLevels()
}
private fun loadModerationLevels() {
val levels = mutableMapOf<String, Int>()
levels["hate"] = prefs.getInt("offensive_level", 2)
levels["sexual"] = prefs.getInt("explicit_level", 1)
levels["violence"] = prefs.getInt("violence_level", 1)
_moderationLevels.value = levels
}
fun setModerationLevel(category: String, level: Int) {
when (category) {
"hate" -> prefs.edit().putInt("offensive_level", level).apply()
"sexual" -> prefs.edit().putInt("explicit_level", level).apply()
"violence" -> prefs.edit().putInt("violence_level", level).apply()
}
val currentMap = _moderationLevels.value?.toMutableMap() ?: mutableMapOf()
currentMap[category] = level
_moderationLevels.value = currentMap
}
fun getModerationLevelsJson(): String {
val json = JSONObject()
moderationLevels.value?.forEach { (key, value) ->
json.put(key, value)
}
return json.toString()
}
fun shouldFilter(category: String, contentLevel: Int): Boolean {
val userLevel = when (category) {
"hate" -> prefs.getInt("offensive_level", 2)
"sexual" -> prefs.getInt("explicit_level", 1)
"violence" -> prefs.getInt("violence_level", 1)
else -> 3
}
return contentLevel > userLevel
}
fun getCurrentModerationLevels(): Map<String, Int>? {
return moderationLevels.value
}
companion object {
@Volatile
private var instance: ModerationsManager? = null
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = ModerationsManager(context.applicationContext)
}
}
}
}
fun getInstance(): ModerationsManager {
return instance ?: throw IllegalStateException("ModerationsManager not initialized")
}
}
}
@@ -66,10 +66,9 @@ class DownloadService : Service() {
return START_NOT_STICKY;
if(!FragmentedStorage.isInitialized) {
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
stopSelf()
closeDownloadSession();
return START_NOT_STICKY;
Logger.i(TAG, "Attempted to start DownloadService without initialized files")
closeDownloadSession()
return START_NOT_STICKY
}
_started = true;
}
@@ -107,12 +106,19 @@ class DownloadService : Service() {
return START_STICKY;
}
fun setupNotificationRequirements() {
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
this.enableVibration(false);
this.setSound(null, null);
};
_notificationManager!!.createNotificationChannel(_notificationChannel!!);
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (_notificationChannel == null) {
_notificationChannel = NotificationChannel(
DOWNLOAD_NOTIF_CHANNEL_ID,
DOWNLOAD_NOTIF_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
enableVibration(false)
setSound(null, null)
setShowBadge(false)
}
}
_notificationManager?.createNotificationChannel(_notificationChannel!!)
}
override fun onCreate() {
@@ -293,21 +299,28 @@ class DownloadService : Service() {
val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC);
if (_isForeground) {
_notificationManager?.notify(DOWNLOAD_NOTIF_ID, notif)
} else {
startForeground(DOWNLOAD_NOTIF_ID, notif);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
else
startForeground(DOWNLOAD_NOTIF_ID, notif)
_isForeground = true
}
}
fun closeDownloadSession() {
Logger.i(TAG, "closeDownloadSession");
stopForeground(STOP_FOREGROUND_REMOVE);
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID);
stopService();
_started = false;
super.stopSelf();
Logger.i(TAG, "closeDownloadSession")
if (_isForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
_isForeground = false
}
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID)
_started = false
super.stopSelf()
}
override fun onDestroy() {
Logger.i(TAG, "onDestroy");
_instance = null;
@@ -20,6 +20,7 @@ import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.work.*
import com.curlbind.Libcurl
import com.futo.platformplayer.*
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs.Action
@@ -28,8 +29,6 @@ import com.futo.platformplayer.UIDialogs.Companion.showDialog
import com.futo.platformplayer.activities.CaptchaActivity
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.activities.SettingsActivity.Companion.settingsActivityClosed
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.background.BackgroundWorker
@@ -38,6 +37,7 @@ import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.main.HomeFragment
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
import com.futo.platformplayer.fragment.mainactivity.main.SourceDetailFragment
import com.futo.platformplayer.logging.AndroidLogConsumer
import com.futo.platformplayer.logging.FileLogConsumer
@@ -49,8 +49,11 @@ import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.views.ToastView
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.toBase64Url
import com.futo.platformplayer.polycentric.ModerationsManager
import kotlinx.coroutines.*
import java.io.File
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
@@ -78,6 +81,9 @@ class StateApp {
privateModeChanged.emit(privateMode);
}
var hasMediaStoreAudioPermission: Boolean = false;
var hasMediaStoreVideoPermission: Boolean = false;
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
val generalUri = Settings.instance.storage.getStorageGeneralUri();
if(isValidStorageUri(context, generalUri))
@@ -135,8 +141,12 @@ class StateApp {
return _scope;
}
val scope: CoroutineScope get() {
val thisScope = scopeOrNull
?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
val thisScope = scopeOrNull;
if(thisScope == null) {
//throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
Logger.w(TAG, "Attempted to use a global lifetime scope while MainActivity is no longer available, USING GLOBAL SCOPE");
return GlobalScope;
}
return thisScope;
}
val scopeGetter: ()->CoroutineScope get() {
@@ -155,6 +165,12 @@ class StateApp {
?: throw IllegalStateException("Attempted to use a global context while MainActivity is no longer available");
return thisContext;
}
val activity: MainActivity? get() {
val context = contextOrNull;
if(context is MainActivity)
return context;
return null;
}
private var _mainId: String? = null;
@@ -167,6 +183,9 @@ class StateApp {
private var _lastMeteredState: Boolean = false;
private var _connectivityManager: ConnectivityManager? = null;
private var _lastNetworkState: NetworkState = NetworkState.UNKNOWN;
private var _lastConnectivityChange: OffsetDateTime? = null;
val lastConnectivityChange
get() = _lastConnectivityChange;
//Logging
private var _fileLogConsumer: FileLogConsumer? = null;
@@ -270,29 +289,52 @@ class StateApp {
};
}
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) {
return requestDirectoryAccess(activity, name, purpose, path, handle, false);
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit, skipDialog: Boolean = false)
{
if(activity is Context)
{
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
if(skipDialog) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
else {
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
UIDialogs.Action("Cancel", {}),
UIDialogs.Action("Ok", {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
activity.launchForResult(intent, 99) {
if(it.resultCode == Activity.RESULT_OK) {
handle(it.data?.data);
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}, UIDialogs.ActionStyle.PRIMARY));
}
}
@@ -376,11 +418,44 @@ class StateApp {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true);
_scope?.launch(Dispatchers.IO) {
try {
val caFile = AppCaUpdater.ensureCaBundle(context)
Libcurl.setDefaultCAPath(caFile.absolutePath)
} catch (t: Throwable) {
val fallback = File(context.noBackupFilesDir, "curl-ca-bundle.pem")
if (fallback.exists()) Libcurl.setDefaultCAPath(fallback.absolutePath)
}
}
if(Settings.instance.other.polycentricLocalCache) {
Logger.i(TAG, "Initialize Polycentric Disk Cache")
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
Logger.i(TAG, "MainApp Starting: Setting [ModerationLevelProvider]");
ApiMethods.setModerationLevelProvider {
try {
ModerationsManager.getInstance().getCurrentModerationLevels()
} catch (e: IllegalStateException) {
Logger.e(TAG, "Failed to get moderation levels from manager", e);
null
}
}
Logger.i(TAG, "MainApp Starting: Setting [ModerationExemptSystemProvider]");
ApiMethods.setModerationExemptSystemProvider {
try {
StatePolycentric.instance.processHandle?.system?.toProto()?.toByteArray()?.toBase64Url()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get moderation exempt system from manager", e);
null
}
}
val logFile = File(context.filesDir, "log.txt");
if (Settings.instance.logging.logLevel > LogLevel.NONE.value) {
val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false);
@@ -420,7 +495,7 @@ class StateApp {
StateSync.instance.start(context)
}
settingsActivityClosed.subscribe {
SettingsFragment.onClosed.subscribe {
if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context)
} else {
@@ -432,7 +507,7 @@ class StateApp {
scopeOrNull?.launch(Dispatchers.Main) {
try {
if (!it.isNullOrEmpty()) {
(SettingsActivity.getActivity() ?: contextOrNull)?.let { c ->
(StateApp.instance.activity ?: contextOrNull)?.let { c ->
val okButtonAction = Action(c.getString(R.string.ok), {}, ActionStyle.PRIMARY)
val copyButtonAction = Action(c.getString(R.string.copy), {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -561,7 +636,9 @@ class StateApp {
scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
Settings.instance.backup.didAskAutoBackup = true; //Some users have issues with it
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
/*
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
UIDialogs.toast("Missing general directory");
@@ -578,6 +655,7 @@ class StateApp {
Settings.instance.backup.didAskAutoBackup = true;
Settings.instance.save();
});
*/
}
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
if(context is IWithResultLauncher) {
@@ -831,8 +909,11 @@ class StateApp {
val beforeMeteredState = _lastMeteredState;
_lastNetworkState = getCurrentNetworkState();
_lastMeteredState = isCurrentMetered();
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState)
if(beforeNetworkState != _lastNetworkState || beforeMeteredState != _lastMeteredState) {
Logger.i(TAG, "Network capabilities changed (State: ${_lastNetworkState}, Metered: ${_lastMeteredState})");
_lastConnectivityChange = OffsetDateTime.now();
}
} catch(ex: Throwable) {
Logger.w(TAG, "Failed to update network state", ex);
}
@@ -9,7 +9,6 @@ import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
@@ -157,8 +156,8 @@ class StateBackup {
}
catch (exSec: FileNotFoundException) {
Logger.e(TAG, "Failed to access backup file", exSec);
val activity = if(SettingsActivity.getActivity() != null)
SettingsActivity.getActivity();
val activity = if(StateApp.instance.activity != null)
StateApp.instance.activity
else if(StateApp.instance.isMainActive)
StateApp.instance.contextOrNull;
else null;
@@ -226,7 +225,7 @@ class StateBackup {
StateApp.instance.contextOrNull?.let {
val uri = FileProvider.getUriForFile(it, it.resources.getString(R.string.authority), exportFile);
val activity = SettingsActivity.getActivity() ?: return@let;
val activity = StateApp.instance.activity ?: return@let;
activity.startActivity(
ShareCompat.IntentBuilder(activity)
.setType("application/zip")
@@ -439,7 +439,7 @@ class StateDownloads {
} else {
throw NotImplementedError("Unsuported scheme");
}
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null;
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.language, subtitle.format, subtitles!!) else null;
}
fun cleanupDownloads(): Pair<Int, Long> {
@@ -0,0 +1,676 @@
package com.futo.platformplayer.states
import android.content.ContentUris
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Artists
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.PlatformID
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.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.structures.AdhocPager
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.states.Album.Companion.TAG
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.toList
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
class StateLibrary {
private val _files = FragmentedStorage.get<StringArrayStorage>("libraryFiles")
fun getFileDirectories(): List<FileEntry> {
val context = StateApp.instance.contextOrNull ?: return listOf();
return _files.getAllValues().map {
if(it.startsWith("content://")) {
val uri = it.toUri();
val docFile = DocumentFile.fromTreeUri(context, uri) ?: return@map null;
//val access = context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission }
if(!docFile.isDirectory) {
_files.remove(it);
return@map null;
}
if(docFile == null)
return@map null;
return@map FileEntry.fromFile(docFile).apply { this.removable = true }
}
else
FileEntry.fromPath(it);
}.filterNotNull();
}
fun deleteFileDirectory(path: String) {
_files.remove(path);
_files.save();
}
fun addFileDirectory(onAdded: ((entry: FileEntry) -> Unit)? = null, skipDialog: Boolean = false): Boolean {
if(!StateApp.instance.isMainActive)
return false;
val mainActivity = StateApp.instance.contextOrNull as MainActivity? ?: return false;
StateApp.instance.requestDirectoryAccess(mainActivity, "Select Directory",
"Select a directory you would like to make accessible to Grayjay", null, {
if(it != null) {
mainActivity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
try {
val file = DocumentFile.fromTreeUri(mainActivity, it) ?: return@requestDirectoryAccess;
val dir = FileEntry.fromFile(file);
_files.add(dir.path);
_files.save();
onAdded?.invoke(dir);
}
catch(ex: Throwable) {
Logger.e(TAG, "Something went wrong converting requested directory", ex);
}
}
}, skipDialog);
return false;
}
fun searchTracks(str: String): List<IPlatformVideo> {
if(str.isNullOrBlank())
return listOf();
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA,
"LOWER(" + MediaStore.Audio.Media.DISPLAY_NAME + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"),
null) ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return list;
}
fun getAlbums(): List<Album> {
return Album.getAlbums();
}
fun getAlbum(str: String): Album? {
val idLong = str.toLongOrNull();
if(idLong != null)
return getAlbum(idLong);
return null;
}
fun searchAlbums(str: String): List<Album> {
if(str.isNullOrBlank())
return listOf();
return Album.getAlbums("LOWER(" + MediaStore.Audio.Albums.ALBUM + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
}
fun getAlbum(id: Long): Album? {
return Album.getAlbum(id);
}
fun getArtists(ordering: ArtistOrdering): List<Artist> {
return Artist.getArtists(ordering);
}
fun getArtist(str: String): Artist? {
val idLong = str.toLongOrNull();
if(idLong != null)
return getArtist(idLong);
return null;
}
fun searchArtists(str: String): List<Artist> {
if(str.isNullOrBlank())
return listOf();
return Artist.getArtists(ArtistOrdering.TrackCount, "LOWER(" + MediaStore.Audio.Artists.ARTIST + ") LIKE ? ", arrayOf("%" + str.trim().lowercase() + "%"));
}
fun getArtist(id: Long): Artist? {
return Artist.getArtist(id);
}
fun getVideos(buckets: List<String>? = null): IPager<IPlatformContent> {
var query = if(buckets != null) "${MediaStore.Video.Media.BUCKET_DISPLAY_NAME} IN " + "(" + buckets.map { "'${it}'" }.joinToString(",") + ")" else null;
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION_VIDEO,
query,
null,
MediaStore.Video.Media.DATE_ADDED + " DESC") ?: return EmptyPager();
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
}
return AdhocPager<IPlatformContent>({
val list = mutableListOf<IPlatformContent>()
while(!cursor.isAfterLast && list.size < 10) {
list.add(videoFromCursor(cursor));
cursor.moveToNext();
}
return@AdhocPager list;
}, list);
}
fun getRecentVideos(buckets: List<String>? = null, count: Int = 20): List<IPlatformVideo> {
val videoPager = getVideos(buckets);
val items = mutableListOf<IPlatformVideo>();
while(videoPager.getResults().size > 0 && items.size < count) {
items.addAll(videoPager.getResults().filter { it is IPlatformVideo }.map { it as IPlatformVideo });
if(videoPager.hasMorePages())
videoPager.nextPage();
}
return items;
}
private var _cacheBucketNames: List<Bucket>? = null;
fun getVideoBucketNames(): List<Bucket> {
if(_cacheBucketNames != null)
return _cacheBucketNames ?: listOf();
val cur: Cursor = StateApp.instance.contextOrNull?.contentResolver?.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
), null, null, null
) ?: return listOf();
val buckets = mutableListOf<Bucket>();
val list = HashSet<Long>();
if (cur.moveToFirst()) {
var id: Long;
var bucket: String
do {
id = cur.getLong(0);
bucket = cur.getString(1)
if(!list.contains(id)) {
list.add(id);
buckets.add(Bucket(id, bucket));
}
} while (cur.moveToNext())
}
_cacheBucketNames = buckets.toList()
return _cacheBucketNames ?: listOf();
}
companion object {
val PROJECTION_VIDEO = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.AUTHOR,
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
);
val PROJECTION_MEDIA = arrayOf(
MediaStore.Audio.Media._ID, //0
MediaStore.Audio.Media.DISPLAY_NAME, //1
MediaStore.Audio.Media.ARTIST, //2
MediaStore.Audio.Media.ALBUM_ID, //3
MediaStore.Audio.Media.DURATION, //4
MediaStore.Audio.Media.DATE_ADDED, //5
MediaStore.Audio.Media.MIME_TYPE, //6
MediaStore.Audio.Media.BUCKET_DISPLAY_NAME //7
);
fun getDocumentTrack(url: String): IPlatformContentDetails? {
if(!url.contains("com.android.externalstorage.documents"))
return null;
val docFile = DocumentFile.fromSingleUri(StateApp.instance.context, url.toUri()) ?: return null;
val contentUri = docFile.uri.toString();
val mimeType = MimeTypeMap.getFileExtensionFromUrl(contentUri);
if(docFile.name != null) {
if (StateApp.instance.hasMediaStoreAudioPermission && mimeType.startsWith("audio/")) {
val aud = findAudioByName(docFile.name!!);
if (aud != null)
return aud;
}
if (StateApp.instance.hasMediaStoreVideoPermission && mimeType.startsWith("video/")) {
val vid = findVideoByName(docFile.name!!);
if (vid != null)
return vid;
}
}
return LocalVideoDetails(
PlatformID("FILE", contentUri, null, 0, -1),
docFile.name ?: docFile.uri.toString(), Thumbnails(arrayOf(
Thumbnail(docFile.uri.toString(), 0)
)), PlatformAuthorLink.UNKNOWN, contentUri, 0, mimeType, null);
}
fun getAudioTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null) {
return getDocumentTrack(url);
}
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media._ID} = ?", arrayOf(id.toString()),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return audioFromCursor(cursor);
}
fun findAudioByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Audio contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return audioFromCursor(cursor);
}
fun getVideoTrack(url: String): IPlatformContentDetails? {
val uri = Uri.parse(url);
val id = uri.lastPathSegment?.toLongOrNull();
if(id == null)
return getDocumentTrack(url);
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media._ID} = ?", arrayOf(id.toString()),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return videoFromCursor(cursor);
}
fun findVideoByName(name: String): IPlatformContentDetails? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null;
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_VIDEO, "${MediaStore.Video.Media.DISPLAY_NAME} = ?", arrayOf(name),
null) ?: return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return videoFromCursor(cursor);
}
fun audioFromCursor(cursor: Cursor): IPlatformVideoDetails {
val id = cursor.getString(0);
val displayName = cursor.getString(1);
val author = cursor.getString(2);
val albumId = cursor.getLong(3);
val duration = cursor.getLong(4).let { if(it > 0) it / 1000 else 0 };
val date = cursor.getLong(5);
val contentType = cursor.getString(6);
val category = cursor.getString(7);
val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null )
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, idLong).toString();
else
"";
val albumContentUrl = if(albumId > 0)
ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, albumId)?.toString()
else null;
val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
else null;
val authorObj = if(!author.isNullOrBlank())
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
else PlatformAuthorLink.UNKNOWN;
return LocalVideoDetails(
PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf(
Thumbnail(albumContentUrl ?: contentUrl, 0)
)), authorObj, contentUrl, duration, contentType, dateObj);
}
fun videoFromCursor(cursor: Cursor): IPlatformVideoDetails {
val id = cursor.getString(0);
val displayName = cursor.getString(1);
val author = cursor.getString(2);
val date = cursor.getLong(3);
val contentType = cursor.getString(4);
val category = cursor.getString(5);
val idLong = id.toLongOrNull();
val contentUrl = if(idLong != null )
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, idLong).toString();
else
"";
val dateObj = if(date > 0)
OffsetDateTime.ofInstant(Instant.ofEpochSecond(date), ZoneOffset.UTC)
else null;
val authorObj = if(!author.isNullOrBlank())
PlatformAuthorLink(PlatformID.NONE, author, "", null, null)
else PlatformAuthorLink.UNKNOWN;
return LocalVideoDetails(
PlatformID("FILE", contentUrl, null, 0, -1),
displayName, Thumbnails(arrayOf(
Thumbnail(contentUrl, 0)
)), authorObj, contentUrl, -1, contentType, dateObj);
}
private var _instance : StateLibrary? = null;
val instance : StateLibrary
get(){
if(_instance == null)
_instance = StateLibrary();
return _instance!!;
};
fun finish() {
_instance?.let {
_instance = null;
}
}
}
}
class Bucket(val id: Long, val name: String);
enum class ArtistOrdering {
Alphabethic,
TrackCount,
AlbumCount
}
class Artist {
val id: String;
val name: String;
val countTracks: Int;
val countAlbums: Int;
val thumbnail: String?;
val contentUrl: String?;
constructor(name: String, countTracks: Int = -1, countAlbums: Int = -1, thumbnail: String? = null, id: String? = null, contentUrl: String? = null) {
this.id = id ?: ID_UNKNOWN;
this.name = name;
this.thumbnail = thumbnail;
this.countTracks = countTracks;
this.countAlbums = countAlbums;
this.contentUrl = contentUrl;
}
fun getAlbums(): List<Album> {
return Album.getArtistAlbums(id.toLongOrNull() ?: return listOf());
}
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getAudioTracks().toList().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) })
}
fun getAudioTracks(): IPager<IPlatformContent> {
val idLong = id.toLongOrNull() ?: return EmptyPager();
return AdhocPager({ listOf() }, getTracksPager(idLong));
}
companion object {
val ID_UNKNOWN = "UNKNOWN";
val PROJECTION: Array<String> = arrayOf(Artists._ID,
Artists.ARTIST,
Artists.NUMBER_OF_TRACKS,
Artists.NUMBER_OF_ALBUMS);
fun fromCursor(cursor: Cursor): Artist {
val id = cursor.getString(0);
val artist = cursor.getString(1);
val numTracks = cursor.getInt(2);
val numAlbums = cursor.getInt(3);
val idLong = id.toLongOrNull();
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
return Artist(artist, numTracks, numAlbums, null, id, uri?.toString());
}
fun getArtist(id: Long): Artist? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Artist contentResolver not found");
return null
}
val cursor = resolver.query(MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI,
Artist.PROJECTION,
"${MediaStore.Audio.Artists._ID} = ?",
arrayOf(id.toString()), null) ?:
return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return Artist.fromCursor(cursor);
}
fun getArtists(ordering: ArtistOrdering = ArtistOrdering.Alphabethic, query: String? = null, args: Array<String>? = null): List<Artist> {
val ordering = when(ordering) {
ArtistOrdering.Alphabethic -> Artists.ARTIST + " ASC";
ArtistOrdering.AlbumCount -> Artists.NUMBER_OF_ALBUMS + " DESC";
ArtistOrdering.TrackCount -> Artists.NUMBER_OF_TRACKS + " DESC";
else -> null
}
val cursor = StateApp.instance.contextOrNull?.contentResolver?.query(Artists.EXTERNAL_CONTENT_URI, PROJECTION,
query,
args,
ordering) ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<Artist>()
while(!cursor.isAfterLast) {
list.add(fromCursor(cursor));
cursor.moveToNext();
}
return list;
}
fun getTracksPager(artistId: Long): List<IPlatformVideo> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
null) ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return list;
}
}
}
class Album {
val id: String;
val name: String;
val artist: String?;
val countTracks: Int;
val thumbnail: String?;
constructor(name: String, countTracks: Int = -1, artist: String? = null, id: String? = null, thumbnail: String? = null) {
this.id = id ?: ID_UNKNOWN;
this.name = name;
this.artist = artist;
this.countTracks = countTracks;
this.thumbnail = thumbnail;
}
fun getTracks(): List<IPlatformVideo> {
return getAlbumTracks(id.toLongOrNull() ?: return listOf())
}
fun toPlaylist(tracks: List<IPlatformVideo>? = null): Playlist {
return Playlist(name, tracks?.map { SerializedPlatformVideo.fromVideo(it) } ?: getTracks().map { SerializedPlatformVideo.fromVideo(it) })
}
companion object {
val TAG = "StateLibrary";
val ID_UNKNOWN = "UNKNOWN";
val PROJECTION = arrayOf(MediaStore.Audio.Albums.ALBUM_ID,
MediaStore.Audio.Albums.ALBUM,
MediaStore.Audio.Albums.NUMBER_OF_SONGS,
MediaStore.Audio.Albums.ARTIST);
fun fromCursor(cursor: Cursor): Album {
val id = cursor.getString(0);
val album = cursor.getString(1);
val numTracks = cursor.getInt(2);
val artist = cursor.getString(3);
val idLong = id.toLongOrNull();
val uri = if(idLong != null) ContentUris.withAppendedId(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, idLong) else null;
return Album(album, numTracks, artist, id, uri?.toString());
}
fun getAlbumTracks(albumId: Long): List<IPlatformVideo> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, StateLibrary.PROJECTION_MEDIA, "${MediaStore.Audio.Media.ALBUM_ID} = ?", arrayOf(albumId.toString()),
null) ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<IPlatformVideo>()
while(!cursor.isAfterLast) {
list.add(StateLibrary.audioFromCursor(cursor));
cursor.moveToNext();
}
return list;
}
fun getAlbum(id: Long): Album? {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return null
}
val cursor = resolver.query(MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI,
PROJECTION,
"${MediaStore.Audio.Albums.ALBUM_ID} = ?",
arrayOf(id.toString()), null) ?:
return null;
cursor.moveToFirst();
if(cursor.isAfterLast)
return null;
return fromCursor(cursor);
}
fun getAlbums(query: String? = null, args: Array<String>? = null): List<Album> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, query, args,
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<Album>()
while(!cursor.isAfterLast) {
list.add(fromCursor(cursor));
cursor.moveToNext();
}
return list;
}
fun getArtistAlbums(artistId: Long): List<Album> {
val resolver = StateApp.instance.contextOrNull?.contentResolver;
if(resolver == null) {
Logger.w(TAG, "Album contentResolver not found");
return listOf();
}
val cursor = resolver?.query(
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, PROJECTION, "${MediaStore.Audio.Media.ARTIST_ID} = ?", arrayOf(artistId.toString()),
MediaStore.Audio.Albums.ALBUM + " ASC") ?: return listOf();
cursor.moveToFirst();
val list = mutableListOf<Album>()
while(!cursor.isAfterLast) {
list.add(fromCursor(cursor));
cursor.moveToNext();
}
return list;
}
}
}
class FileEntry(
val path: String,
val name: String,
val isDirectory: Boolean = false,
val thumbnail: String? = null,
var removable: Boolean = false
) {
fun getSubFiles(): List<FileEntry> {
if(isDirectory) {
if(path.startsWith("content://"))
return DocumentFile.fromTreeUri(StateApp.instance.context, path.toUri())?.listFiles()
?.map { fromFile(it) } ?: return listOf();
return File(path).listFiles()
.map { fromFile(it) }
}
return listOf();
}
companion object {
fun fromPath(path: String): FileEntry {
/*
val cursor = StateApp.instance.context.contentResolver.query(path.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return FileEntry(path, fileName, );
*/
val file = File(path);
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: File): FileEntry {
return FileEntry(file.path, file.name, file.isDirectory);
}
fun fromFile(file: DocumentFile): FileEntry {
return FileEntry(file.uri.toString(), file.name ?: "", file.isDirectory);
}
}
}
@@ -28,6 +28,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.local.LocalClient
import com.futo.platformplayer.api.media.structures.EmptyPager
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
@@ -75,6 +76,7 @@ class StatePlatform {
private val _cache : LruCache<String, CachedPlatformContent> = LruCache<String, CachedPlatformContent>(VIDEO_CACHE);
//Clients
private val _localClient = LocalClient();
private val _enabledClientsPersistent = FragmentedStorage.get<StringArrayStorage>("enabledClients");
private val _platformOrderPersistent = FragmentedStorage.get<StringArrayStorage>("platformOrder");
private val _clientsLock = Object();
@@ -117,6 +119,7 @@ class StatePlatform {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_mainClientPool.getClientPooled(it).getContentDetails(url)
}
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
}
else {
@@ -124,6 +127,7 @@ class StatePlatform {
_enabledClients.find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) }?.let {
_privateClientPool.getClientPooled(it).getContentDetails(url)
}
?: (if(_localClient.isContentDetailsUrl(url)) _localClient.getContentDetails(url) else null)
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
}
},
@@ -177,16 +181,11 @@ class StatePlatform {
}
withContext(Dispatchers.IO) {
var toDisables = mutableListOf<IPlatformClient>();
var enabled: Array<String>;
synchronized(_clientsLock) {
for(e in _enabledClients) {
try {
e.disable();
onSourceDisabled.emit(e);
}
catch(ex: Throwable) {
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
}
toDisables.add(e);
}
_enabledClients.clear();
@@ -236,6 +235,18 @@ class StatePlatform {
}
}
selectClients(*enabled);
for(toDisable in toDisables) {
launch(Dispatchers.IO) {
try {
toDisable.disable();
onSourceDisabled.emit(toDisable);
}
catch(ex: Throwable) {
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER UpdateAvailableClients", ex);
}
}
}
};
}
@@ -348,11 +359,11 @@ class StatePlatform {
StateApp.instance.handleCaptchaException(c, ex);
}
var toDisable: IPlatformClient? = null;
synchronized(_clientsLock) {
if (_enabledClients.contains(client)) {
_enabledClients.remove(client);
client.disable();
onSourceDisabled.emit(client);
toDisable = client;
newClient.initialize();
_enabledClients.add(newClient);
}
@@ -360,6 +371,18 @@ class StatePlatform {
_availableClients.removeIf { it.id == id };
_availableClients.add(newClient);
}
if(toDisable != null) {
launch(Dispatchers.IO) {
try {
toDisable?.disable();
onSourceDisabled.emit(client);
}
catch (ex: Throwable) {
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER RELOAD", ex);
}
}
}
afterReload?.invoke();
return@withContext newClient;
};
@@ -402,18 +402,25 @@ class StatePlugins {
}
val icon = config.absoluteIconUrl?.let { absIconUrl ->
withContext(Dispatchers.Main) {
it.setText("Saving plugin...");
it.setProgress(0.75);
}
val iconResp = client.get(absIconUrl);
if(iconResp.isOk)
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
return@let null;
}
withContext(Dispatchers.Main) {
it.setText("Saving plugin...");
it.setProgress(0.75);
}
val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall);
if(installEx != null)
throw installEx;
withContext(Dispatchers.Main) {
it.setText("Reloading available plugins...");
it.setProgress(0.9);
}
StatePlatform.instance.updateAvailableClients(context);
withContext(Dispatchers.Main) {
@@ -522,9 +529,7 @@ class StatePlugins {
if(id == StateDeveloper.DEV_ID)
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
synchronized(_plugins) {
return _plugins.findItem { it.config.id == id };
}
return _plugins.findItem { it.config.id == id };
}
fun getPlugins(): List<SourcePluginDescriptor> {
return _plugins.getItems();
@@ -533,12 +538,10 @@ class StatePlugins {
fun deletePlugin(id: String) {
synchronized(_pluginScripts) {
synchronized(_plugins) {
_pluginScripts.deleteFile(id);
val plugins = _plugins.findItems { it.config.id == id };
for(plugin in plugins)
_plugins.delete(plugin);
}
_pluginScripts.deleteFile(id);
val plugins = _plugins.findItems { it.config.id == id };
for(plugin in plugins)
_plugins.delete(plugin);
}
}
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
@@ -28,6 +28,7 @@ import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringStorage
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ensureServerAndBackfill
import com.futo.polycentric.core.ClaimType
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Opinion
@@ -46,8 +47,10 @@ import com.google.protobuf.ByteString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import userpackage.Protocol
import userpackage.Protocol.Reference
@@ -67,6 +70,8 @@ class StatePolycentric {
private val _commentPool = ForkJoinPool(2);
private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher();
private val _backgroundJob = SupervisorJob()
private val _backgroundScope = CoroutineScope(_backgroundJob + Dispatchers.IO)
fun load(context: Context) {
if (!enabled) {
@@ -173,6 +178,15 @@ class StatePolycentric {
}
_likeDislikeMap = newMap
// Ensure current server is registered & synced
_backgroundScope.launch {
try {
processHandle.ensureServerAndBackfill()
} catch (e: Throwable) {
Logger.w(TAG, "Failed to ensure server and backfill: "+e.message)
}
}
} else {
_activeProcessHandle.setAndSave("");
_likeDislikeMap = hashMapOf()
@@ -559,6 +573,11 @@ class StatePolycentric {
};
}
fun cleanup() {
_backgroundJob.cancel()
_commentPool.shutdown()
}
companion object {
private const val TAG = "StatePolycentric";
@@ -6,6 +6,7 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.util.Log
import com.futo.platformplayer.Settings
import com.futo.platformplayer.ensureNotMainThread
import com.futo.platformplayer.generateReadablePassword
import com.futo.platformplayer.getConnectedSocket
import com.futo.platformplayer.logging.Logger
@@ -17,14 +18,23 @@ import com.futo.polycentric.core.base64UrlToByteArray
import com.futo.polycentric.core.toBase64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.channels.ClosedChannelException
import java.nio.channels.ServerSocketChannel
import java.nio.channels.SocketChannel
import java.util.Base64
import java.util.Locale
import kotlin.math.min
@@ -64,11 +74,7 @@ class SyncService(
private val database: ISyncDatabaseProvider,
private val settings: SyncServiceSettings = SyncServiceSettings()
) {
private var _serverSocket: ServerSocket? = null
private var _thread: Thread? = null
private var _connectThread: Thread? = null
private var _mdnsThread: Thread? = null
@Volatile private var _started = false
private var _serverSocket: ServerSocketChannel? = null
private val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
@@ -82,10 +88,10 @@ class SyncService(
private val _pairingCode: String? = generateReadablePassword(8)
val pairingCode: String? get() = _pairingCode
private var _relaySession: SyncSocketSession? = null
private var _threadRelay: Thread? = null
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private val _remotePendingStatusUpdateRelayed = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private val _remotePendingStatusUpdateDirect = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null
private var _scope: CoroutineScope? = null
@Volatile private var _scope: CoroutineScope? = null
private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>()
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
@@ -216,11 +222,12 @@ class SyncService(
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context) {
if (_started) {
Logger.i(TAG, "Already started.")
if (_scope != null) {
Log.i(TAG, "Already started.")
return
}
_started = true
Log.i(TAG, "Start SyncService.")
_scope = CoroutineScope(Dispatchers.IO)
try {
@@ -294,27 +301,30 @@ class SyncService(
private fun startListener() {
serverSocketFailedToStart = false
serverSocketStarted = false
_thread = Thread {
_scope?.launch(Dispatchers.IO) {
try {
val serverSocket = ServerSocket(settings.listenerPort)
val serverSocket = ServerSocketChannel.open()
serverSocket.socket().bind(InetSocketAddress("0.0.0.0", settings.listenerPort))
_serverSocket = serverSocket
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
serverSocketStarted = true
while (_started) {
while (isActive) {
val socket = serverSocket.accept()
val session = createSocketSession(socket, true)
//TODO: Switch to SocketChannel?
val session = createSocketSession(socket.socket(), true)
session.startAsResponder()
}
serverSocketStarted = false
} catch (e: ClosedChannelException) {
// normal shutdown
} catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
Log.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true
} finally {
serverSocketStarted = false
}
}.apply { start() }
}
}
private fun startMdnsRetryLoop() {
@@ -322,43 +332,44 @@ class SyncService(
discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
}
_mdnsThread = Thread {
while (_started) {
_scope?.launch(Dispatchers.IO) {
while (isActive) {
try {
val now = System.currentTimeMillis()
synchronized(_mdnsCache) {
for ((pkey, info) in _mdnsCache) {
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
val pairs = synchronized (_mdnsCache) { _mdnsCache.toList() }
for ((pkey, info) in pairs) {
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
}
if (now - last > 30_000L) {
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
}
if (now - last > 30_000L) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
try {
Logger.i(TAG, "MDNS-retry: connecting to $pkey")
connect(info)
} catch (ex: Throwable) {
Logger.w(TAG, "MDNS retry failed for $pkey", ex)
}
}
try {
Log.i(TAG, "MDNS-retry: connecting to $pkey")
connect(info)
if (!isActive) break
} catch (ex: Throwable) {
Log.w(TAG, "MDNS retry failed for $pkey", ex)
}
}
}
} catch (ex: Throwable) {
Logger.e(TAG, "Error in MDNS retry loop", ex)
Log.e(TAG, "Error in MDNS retry loop", ex)
}
Thread.sleep(5000)
delay(5000)
}
}.apply { start() }
}
}
private fun startConnectLastLoop() {
_connectThread = Thread {
_scope?.launch(Dispatchers.IO) {
Log.i(TAG, "Running auto reconnector")
while (_started) {
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf()
while (isActive) {
val authorizedDevices = database.getAllAuthorizedDevices()?.toList() ?: listOf()
val addressesToConnect = authorizedDevices.mapNotNull {
val connectedDirectly = getLinkType(it) == LinkType.Direct
if (connectedDirectly) {
@@ -382,26 +393,26 @@ class SyncService(
_lastConnectTimesIp[connectPair.first] = now
}
Logger.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
Log.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
connect(arrayOf(connectPair.second), settings.listenerPort, connectPair.first, null)
}
} catch (e: Throwable) {
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
Log.i(TAG, "Failed to connect to " + connectPair.first, e)
}
}
Thread.sleep(5000)
delay(5000)
}
}.apply { start() }
}
}
private fun startRelayLoop() {
relayConnected = false
_threadRelay = Thread {
_scope?.launch(Dispatchers.IO) {
try {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
while (_started) {
while (isActive) {
try {
Log.i(TAG, "Starting relay session...")
relayConnected = false
@@ -465,7 +476,7 @@ class SyncService(
Thread {
try {
while (_started && !socketClosed) {
while (isActive && !socketClosed) {
val unconnectedAuthorizedDevices =
database.getAllAuthorizedDevices()
?.filter {
@@ -503,27 +514,14 @@ class SyncService(
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp }
if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread {
launch(Dispatchers.IO) {
try {
Log.v(
TAG,
"Attempting to connect directly, locally to '$targetKey'."
)
connect(
potentialLocalAddresses.map { it }
.toTypedArray(),
settings.listenerPort,
targetKey,
null
)
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null)
} catch (e: Throwable) {
Log.e(
TAG,
"Failed to start direct connection using connection info with $targetKey.",
e
)
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e)
}
}.start()
}
}
if (connectionInfo.allowRemoteDirect) {
@@ -587,7 +585,7 @@ class SyncService(
} catch (ex: Throwable) {
Log.i(TAG, "Unhandled exception in relay loop.", ex)
}
}.apply { start() }
}
}
private fun createSocketSession(socket: Socket, isResponder: Boolean): SyncSocketSession {
@@ -699,14 +697,21 @@ class SyncService(
return _pairingCode == pairingCode
}
private fun sendRemotePendingStatusUpdate(remotePublicKey: String, complete: Boolean, message: String) {
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect.remove(remotePublicKey)?.invoke(complete, message)
}
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed.remove(remotePublicKey)?.invoke(complete, message)
}
}
private fun createNewSyncSession(rpk: String, remoteDeviceName: String?): SyncSession {
val remotePublicKey = rpk.base64ToByteArray().toBase64()
return SyncSession(
remotePublicKey,
onAuthorized = { it, isNewlyAuthorized, isNewSession ->
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
sendRemotePendingStatusUpdate(remotePublicKey, true, "Authorized")
if (isNewSession) {
it.remoteDeviceName?.let { remoteDeviceName ->
@@ -719,10 +724,7 @@ class SyncService(
onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession)
},
onUnauthorized = {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
sendRemotePendingStatusUpdate(remotePublicKey, false, "Unauthorized")
onUnauthorized?.invoke(it)
},
onConnectedChanged = { it, connected ->
@@ -733,9 +735,7 @@ class SyncService(
Logger.i(TAG, "$remotePublicKey closed")
removeSession(it.remotePublicKey)
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
}
sendRemotePendingStatusUpdate(remotePublicKey, false, "Connection closed")
onClose?.invoke(it)
},
@@ -757,42 +757,67 @@ class SyncService(
fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices()
fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey)
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect directly", e)
val relaySession = _relaySession
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
onStatusUpdate?.invoke(null, "Connecting via relay...")
runBlocking {
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
suspend fun connect(deviceInfo: SyncDeviceInfo, alsoTryRelayed: Boolean = false, timeout_ms: Int = 10_000, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
val rs = _relaySession
val startTime = System.currentTimeMillis()
if (alsoTryRelayed && rs != null && settings.relayPairAllowed) {
onStatusUpdate?.invoke(null, "Connecting via relay...")
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
} else {
throw e
}
//TODO: Do not try relayed channel here only for pairing mode?
rs.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
}
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate, timeout_ms)
} catch (e: Throwable) {
Log.e(TAG, "Failed to connect directly", e)
val waitTime_ms = timeout_ms - (System.currentTimeMillis() - startTime)
if (waitTime_ms > 0)
delay(waitTime_ms)
onStatusUpdate?.invoke(false, "Failed to connect.")
synchronized(_remotePendingStatusUpdateRelayed) {
_remotePendingStatusUpdateRelayed.remove(deviceInfo.publicKey.base64ToByteArray().toBase64())
}
}
}
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession {
suspend fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null, timeout_ms: Int = 10_000): SyncSocketSession {
val startTime_ms = System.currentTimeMillis()
Log.i(TAG, "Connecting directly (timeout_ms = ${timeout_ms})...")
onStatusUpdate?.invoke(null, "Connecting directly...")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port, timeout_ms) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, "Handshaking...")
val session = createSocketSession(socket, false)
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
session.startAsInitiator(publicKey, appId, pairingCode)
while ((System.currentTimeMillis() - startTime_ms) < timeout_ms && !session.isAuthorized) {
delay(100)
}
if (!session.isAuthorized) {
Log.i(TAG, "Session is not authorized after timeout, cancelling connection.")
session.stop()
onStatusUpdate?.invoke(false, "Session not authorized.")
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect.remove(publicKey.base64ToByteArray().toBase64())
}
}
return session
}
@@ -811,6 +836,8 @@ class SyncService(
synchronized(_sessions) {
_sessions.clear()
}
_remotePendingStatusUpdateDirect.clear()
_remotePendingStatusUpdateRelayed.clear()
}
private fun getDeviceName(): String {
@@ -56,6 +56,7 @@ class SyncSocketSession {
private var _remotePublicKey: String? = null
val remotePublicKey: String? get() = _remotePublicKey
private var _started: Boolean = false
val started get() = _started
private val _localKeyPair: DHState
private var _thread: Thread? = null
private var _localPublicKey: String
@@ -0,0 +1,70 @@
package com.futo.platformplayer.views
import android.content.Context
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.bumptech.glide.Glide
import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1
class AlbumHeaderView: ConstraintLayout {
val textName: TextView;
val textMetadata: TextView;
val imageThumbnail: ImageView;
val imageThumbnailBackground: ImageView;
val buttonPlayAll: LinearLayout;
val buttonShuffle: LinearLayout;
val onPlayAll = Event0();
val onShuffle = Event0();
constructor(context: Context) : super(context) {
inflate(context, R.layout.view_album_header, this)
textName = findViewById(R.id.text_name);
textMetadata = findViewById(R.id.text_metadata);
imageThumbnail = findViewById(R.id.image_thumbnail);
imageThumbnailBackground = findViewById(R.id.image_thumbnail_background);
buttonPlayAll = findViewById(R.id.button_play_all);
buttonShuffle = findViewById(R.id.button_shuffle);
buttonPlayAll.setOnClickListener { onPlayAll.emit() };
buttonShuffle.setOnClickListener { onShuffle.emit() };
}
fun setThumbnail(thumbnail: String?) {
if (thumbnail != null)
Glide.with(imageThumbnail)
.load(thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnail)
else
Glide.with(imageThumbnail)
.load(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnail);
if (thumbnail != null)
Glide.with(imageThumbnailBackground)
.load(thumbnail)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnailBackground)
else
Glide.with(imageThumbnailBackground)
.load(R.drawable.placeholder_channel_thumbnail)
.into(imageThumbnailBackground);
}
fun setName(str: String){
textName.text = str;
}
fun setMetadata(str: String) {
textMetadata.text = str;
}
}

Some files were not shown because too many files have changed in this diff Show More