mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 13:02:39 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4433364cd8 | |||
| 2c957d7188 | |||
| f229f4ed1f | |||
| e8d1f73e29 | |||
| dd2cf18cb2 | |||
| 5355602577 | |||
| 8cc82e4d16 | |||
| 4b5ed38175 | |||
| 75eb7359de | |||
| fd519d48cf | |||
| 6f1866ac27 | |||
| 0dc0f07785 | |||
| bae8cb7bc4 | |||
| d5a696289b | |||
| 75ef7085eb | |||
| 347ef855b3 | |||
| aac19aef86 | |||
| 33efc5c21d | |||
| fc7001c295 | |||
| 9b68394f70 | |||
| e2ef8c2593 | |||
| 551bfe44ac | |||
| 6fbfa98ad3 | |||
| 9b97e05e3b | |||
| da44e86163 | |||
| 682b86330e | |||
| c9ba8a09e2 | |||
| 7d19c2357c | |||
| 64030a038c | |||
| 87d93c2ed8 | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| bce93b8e0f | |||
| 9a950958f9 | |||
| b6676e7763 | |||
| 35fe093e5c | |||
| 7cad4fbe07 | |||
| 240772790d | |||
| d659ecc518 | |||
| 7d8bb20b71 | |||
| 1cf5f776d5 | |||
| 137ba85538 | |||
| 642d218c54 | |||
| 26b5470200 | |||
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 | |||
| 19429263a9 | |||
| 986652adab | |||
| 4d93a58d5d | |||
| 817c90f3af | |||
| 77348b3787 | |||
| 31e26d03c6 | |||
| 1ef566ab16 | |||
| 7597f5136c | |||
| 9a2a70622f | |||
| 4fc33411fd | |||
| a9bb900994 | |||
| 8c1a18d8b4 | |||
| 14ae5f1572 | |||
| ed40994600 | |||
| 90e8c35b19 | |||
| 4d017ad357 | |||
| 2ca2a9db23 | |||
| 940bed2cee | |||
| 4eb20a1843 | |||
| 98c6378148 | |||
| bb066a7a31 | |||
| b5d3261f03 | |||
| 755bebaecb | |||
| 004e4be4d3 |
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.ImageButton
|
||||
import android.widget.SeekBar
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.futo.platformplayer.polycentric.ModerationsManager
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
|
||||
class PolycentricModerationActivity : AppCompatActivity() {
|
||||
private lateinit var _seekbarOffensive: SeekBar
|
||||
private lateinit var _seekbarExplicit: SeekBar
|
||||
private lateinit var _seekbarViolence: SeekBar
|
||||
private lateinit var _textOffensiveDesc: TextView
|
||||
private lateinit var _textExplicitDesc: TextView
|
||||
private lateinit var _textViolenceDesc: TextView
|
||||
private lateinit var _textOffensiveValue: TextView
|
||||
private lateinit var _textExplicitValue: TextView
|
||||
private lateinit var _textViolenceValue: TextView
|
||||
private lateinit var _moderationsManager: ModerationsManager
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_polycentric_moderation)
|
||||
setNavigationBarColorAndIcons()
|
||||
|
||||
_moderationsManager = ModerationsManager.getInstance()
|
||||
try {
|
||||
_moderationsManager = ModerationsManager.getInstance()
|
||||
} catch (e: IllegalStateException) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
|
||||
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
|
||||
_seekbarViolence = findViewById(R.id.seekbar_violence)
|
||||
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
|
||||
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
|
||||
_textViolenceDesc = findViewById(R.id.text_violence_desc)
|
||||
_textOffensiveValue = findViewById(R.id.text_offensive_value)
|
||||
_textExplicitValue = findViewById(R.id.text_explicit_value)
|
||||
_textViolenceValue = findViewById(R.id.text_violence_value)
|
||||
|
||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
setupListeners()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
|
||||
|
||||
val offensiveLevel = levels["hate"] ?: 2
|
||||
val explicitLevel = levels["sexual"] ?: 1
|
||||
val violenceLevel = levels["violence"] ?: 1
|
||||
|
||||
_seekbarOffensive.progress = offensiveLevel
|
||||
_seekbarExplicit.progress = explicitLevel
|
||||
_seekbarViolence.progress = violenceLevel
|
||||
|
||||
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||
if (fromUser) {
|
||||
_moderationsManager.setModerationLevel("hate", progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||
})
|
||||
|
||||
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||
if (fromUser) {
|
||||
_moderationsManager.setModerationLevel("sexual", progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||
})
|
||||
|
||||
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||
if (fromUser) {
|
||||
_moderationsManager.setModerationLevel("violence", progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
|
||||
val progress = seekBar?.progress ?: 0
|
||||
textDesc.text = descriptions[progress]
|
||||
textValue.text = progress.toString()
|
||||
}
|
||||
|
||||
private fun getOffensiveDescriptions(): Array<String> {
|
||||
return arrayOf(
|
||||
"Neutral, general terms, no bias or hate.",
|
||||
"Mildly sensitive, factual.",
|
||||
"Potentially offensive content",
|
||||
"Offensive content"
|
||||
)
|
||||
}
|
||||
|
||||
private fun getExplicitDescriptions(): Array<String> {
|
||||
return arrayOf(
|
||||
"No explicit content",
|
||||
"Mildly suggestive, factual or educational",
|
||||
"Moderate sexual content, non-graphic",
|
||||
"Explicit sexual content"
|
||||
)
|
||||
}
|
||||
|
||||
private fun getViolenceDescriptions(): Array<String> {
|
||||
return arrayOf(
|
||||
"Non-violent",
|
||||
"Mild violence, factual or contextual",
|
||||
"Moderate violence, some graphic content.",
|
||||
"Graphic violence"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
private lateinit var _buttonHelp: ImageButton;
|
||||
private lateinit var _editName: EditText;
|
||||
private lateinit var _buttonExport: BigButton;
|
||||
private lateinit var _buttonOpenHarborProfile: BigButton;
|
||||
private lateinit var _buttonModeration: BigButton;
|
||||
private lateinit var _buttonLogout: BigButton;
|
||||
private lateinit var _buttonDelete: BigButton;
|
||||
private lateinit var _username: String;
|
||||
@@ -71,7 +71,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||
_editName = findViewById(R.id.edit_profile_name);
|
||||
_buttonExport = findViewById(R.id.button_export);
|
||||
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
|
||||
_buttonModeration = findViewById(R.id.button_moderation);
|
||||
_buttonLogout = findViewById(R.id.button_logout);
|
||||
_buttonDelete = findViewById(R.id.button_delete);
|
||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||
@@ -99,15 +99,9 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
||||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonOpenHarborProfile.onClick.subscribe {
|
||||
val processHandle = StatePolycentric.instance.processHandle!!;
|
||||
processHandle?.let {
|
||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(it.system));
|
||||
val url = it.system.systemToURLInfoSystemLinkUrl(systemState.servers.asIterable());
|
||||
val navUrl = "https://harbor.social/" + url.substring("polycentric://".length)
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(navUrl)))
|
||||
}
|
||||
}
|
||||
_buttonModeration.onClick.subscribe {
|
||||
startActivity(Intent(this, PolycentricModerationActivity::class.java));
|
||||
};
|
||||
|
||||
_buttonLogout.onClick.subscribe {
|
||||
StatePolycentric.instance.setProcessHandle(null);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+11
-21
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
open class PlatformComment : IPlatformComment {
|
||||
override val contextUrl: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val message: String;
|
||||
override val rating: IRating;
|
||||
override val date: OffsetDateTime;
|
||||
open class PlatformComment(
|
||||
override val contextUrl: String,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val message: String,
|
||||
override val rating: IRating,
|
||||
override val date: OffsetDateTime,
|
||||
override val replyCount: Int? = null
|
||||
) : IPlatformComment {
|
||||
|
||||
override val replyCount: Int?;
|
||||
|
||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) {
|
||||
this.contextUrl = contextUrl;
|
||||
this.author = author;
|
||||
this.message = msg;
|
||||
this.rating = rating;
|
||||
this.date = date;
|
||||
this.replyCount = replyCount;
|
||||
}
|
||||
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
||||
return NoCommentsPager();
|
||||
}
|
||||
}
|
||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
|
||||
NoCommentsPager()
|
||||
}
|
||||
|
||||
+17
-3
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
|
||||
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
||||
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
|
||||
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
override val audioSources: Array<IAudioSource>;
|
||||
|
||||
constructor(video: VideoLocal) {
|
||||
videoSources = video.videoSource.toTypedArray();
|
||||
audioSources = video.audioSource.toTypedArray();
|
||||
}
|
||||
constructor(audio: LocalAudioContentSource) {
|
||||
videoSources = arrayOf()
|
||||
audioSources = arrayOf(audio);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
|
||||
this.videoSources = videoSources;
|
||||
this.audioSources = audioSources;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -14,7 +14,8 @@ class AudioUrlSource(
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false
|
||||
override var original: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+1
@@ -41,6 +41,7 @@ class HLSVariantSubtitleUrlSource(
|
||||
override val format: String,
|
||||
) : ISubtitleSource {
|
||||
override val hasFetch: Boolean = false
|
||||
override val language: String? = null
|
||||
|
||||
override fun getSubtitles(): String? {
|
||||
return null
|
||||
|
||||
+4
-1
@@ -9,13 +9,15 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
override val name: String;
|
||||
override val url: String?;
|
||||
override val format: String?;
|
||||
override val language: String?
|
||||
override val hasFetch: Boolean get() = false;
|
||||
|
||||
val filePath: String;
|
||||
|
||||
constructor(name: String, format: String?, filePath: String) {
|
||||
constructor(name: String, language: String?, format: String?, filePath: String) {
|
||||
this.name = name;
|
||||
this.format = format;
|
||||
this.language = language
|
||||
this.filePath = filePath;
|
||||
this.url = Uri.fromFile(File(filePath)).toString();
|
||||
}
|
||||
@@ -32,6 +34,7 @@ class LocalSubtitleSource : ISubtitleSource {
|
||||
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
|
||||
return LocalSubtitleSource(
|
||||
source.name,
|
||||
source.language,
|
||||
source.format,
|
||||
path
|
||||
);
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
|
||||
@kotlinx.serialization.Serializable
|
||||
class SubtitleRawSource(
|
||||
override val name: String,
|
||||
override val language: String?,
|
||||
override val format: String?,
|
||||
val _subtitles: String,
|
||||
override val url: String? = null,
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ open class VideoUrlSource(
|
||||
override val codec : String = "",
|
||||
override val bitrate : Int? = 0,
|
||||
|
||||
override var priority: Boolean = false
|
||||
override var priority: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ interface ISubtitleSource {
|
||||
val url: String?;
|
||||
val format: String?;
|
||||
val hasFetch: Boolean;
|
||||
val language: String?
|
||||
|
||||
fun getSubtitles(): String?;
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class LocalVideoDetails(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val url: String,
|
||||
override val duration: Long,
|
||||
|
||||
val mimeType: String? = null,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
override val hls: IHLSManifestSource? get() = null;
|
||||
override val live: IVideoSource? get() = null;
|
||||
|
||||
|
||||
override val shareUrl: String = ""
|
||||
override val viewCount: Long = -1
|
||||
override val rating: IRating = RatingLikes(0)
|
||||
override val description: String = "";
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||
override val isShort: Boolean = false
|
||||
|
||||
fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
fun fromJson(str : String) : SerializedPlatformVideoDetails {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
companion object {
|
||||
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
if(filePath.startsWith("content://"))
|
||||
return fromContent(filePath, mimeType);
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
|
||||
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
|
||||
}
|
||||
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getFileNameFromContentUrl(url: String): String? {
|
||||
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
|
||||
cursor?.moveToFirst();
|
||||
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||
cursor?.close();
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+16
-11
@@ -23,17 +23,22 @@ import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticle(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (obj.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
config,
|
||||
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
+24
-23
@@ -24,36 +24,37 @@ import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.invokeV8
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
open class JSArticleDetails(
|
||||
private val client: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
final override val contentType: ContentType = ContentType.ARTICLE
|
||||
|
||||
override val rating: IRating;
|
||||
private val _hasGetComments: Boolean = _content.has("getComments")
|
||||
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
override val rating: IRating =
|
||||
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
|
||||
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
|
||||
?: RatingLikes(0)
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
override val summary: String =
|
||||
_content.getOrThrow(client.config, "summary", "PlatformArticle")
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
if(_content.has("thumbnails"))
|
||||
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
|
||||
override val thumbnails: Thumbnails? =
|
||||
if (_content.has("thumbnails"))
|
||||
Thumbnails.fromV8(
|
||||
client.config,
|
||||
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
|
||||
)
|
||||
else
|
||||
thumbnails = null;
|
||||
null
|
||||
|
||||
|
||||
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
|
||||
?.map { fromV8Segment(client, it) }
|
||||
?.filterNotNull() ?: listOf());
|
||||
|
||||
_hasGetComments = _content.has("getComments");
|
||||
_hasGetContentRecommendations = _content.has("getContentRecommendations");
|
||||
}
|
||||
override val segments: List<IJSArticleSegment> =
|
||||
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
|
||||
?.mapNotNull { fromV8Segment(client, it) }
|
||||
?: emptyList()
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||
if(!_hasGetComments || _content.isClosed)
|
||||
|
||||
+34
-36
@@ -16,51 +16,49 @@ import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
open class JSContent : IPlatformContent, IPluginSourced {
|
||||
protected val _pluginConfig: SourcePluginConfig;
|
||||
protected val _content : V8ValueObject;
|
||||
open class JSContent(
|
||||
protected val _pluginConfig: SourcePluginConfig,
|
||||
protected val _content: V8ValueObject
|
||||
) : IPlatformContent, IPluginSourced {
|
||||
|
||||
protected val _hasGetDetails: Boolean;
|
||||
override val contentType: ContentType = ContentType.UNKNOWN
|
||||
|
||||
override val contentType: ContentType get() = ContentType.UNKNOWN;
|
||||
protected val _hasGetDetails: Boolean = _content.has("getDetails")
|
||||
|
||||
override val id: PlatformID;
|
||||
override val name: String;
|
||||
override val author: PlatformAuthorLink;
|
||||
override val datetime: OffsetDateTime?;
|
||||
override val id: PlatformID =
|
||||
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
|
||||
|
||||
override val url: String;
|
||||
override val shareUrl: String;
|
||||
override val name: String =
|
||||
HtmlCompat.fromHtml(
|
||||
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY
|
||||
).toString()
|
||||
|
||||
override val sourceConfig: SourcePluginConfig get() = _pluginConfig;
|
||||
override val author: PlatformAuthorLink =
|
||||
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
|
||||
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
|
||||
?: PlatformAuthorLink.UNKNOWN
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
|
||||
_pluginConfig = config;
|
||||
_content = obj;
|
||||
private val _epoch: Long? =
|
||||
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
|
||||
|
||||
val contextName = "PlatformContent";
|
||||
override val datetime: OffsetDateTime? =
|
||||
_epoch?.takeIf { it != 0L }?.let {
|
||||
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
|
||||
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
|
||||
override val url: String =
|
||||
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
|
||||
|
||||
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
|
||||
if(authorObj != null)
|
||||
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
|
||||
else
|
||||
author = PlatformAuthorLink.UNKNOWN;
|
||||
override val shareUrl: String =
|
||||
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
|
||||
|
||||
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
|
||||
if(datetimeInt == null || datetimeInt == 0.toLong())
|
||||
datetime = null;
|
||||
else
|
||||
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
|
||||
url = _content.getOrThrow(config, "url", contextName);
|
||||
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
|
||||
override val sourceConfig: SourcePluginConfig
|
||||
get() = _pluginConfig
|
||||
|
||||
_hasGetDetails = _content.has("getDetails");
|
||||
fun getUnderlyingObject(): V8ValueObject? = _content
|
||||
|
||||
companion object {
|
||||
private const val CTX = "PlatformContent"
|
||||
}
|
||||
|
||||
fun getUnderlyingObject(): V8ValueObject? {
|
||||
return _content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -6,14 +6,16 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
|
||||
open class JSPlaylist : JSContent, IPlatformPlaylist {
|
||||
override val contentType: ContentType get() = ContentType.PLAYLIST;
|
||||
override val thumbnail: String?;
|
||||
override val videoCount: Int;
|
||||
open class JSPlaylist(
|
||||
config: SourcePluginConfig,
|
||||
obj: V8ValueObject
|
||||
) : JSContent(config, obj), IPlatformPlaylist {
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
|
||||
val contextName = "Playlist";
|
||||
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
|
||||
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
|
||||
}
|
||||
}
|
||||
override val contentType: ContentType = ContentType.PLAYLIST
|
||||
|
||||
override val thumbnail: String? =
|
||||
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
|
||||
|
||||
override val videoCount: Int =
|
||||
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
|
||||
}
|
||||
|
||||
+2
@@ -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");
|
||||
|
||||
+31
-30
@@ -8,43 +8,44 @@ import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
|
||||
override val name: String;
|
||||
override val bitrate : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
private val url : String;
|
||||
open class JSAudioUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
|
||||
|
||||
override val language: String;
|
||||
private val ctx = "AudioUrlSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
override val duration: Long?;
|
||||
override val bitrate: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
|
||||
|
||||
override var priority: Boolean = false;
|
||||
override val container: String =
|
||||
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||
|
||||
override var original: Boolean = false;
|
||||
override val codec: String =
|
||||
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
|
||||
val contextName = "AudioUrlSource";
|
||||
val config = plugin.config;
|
||||
private val url: String =
|
||||
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||
|
||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
||||
container = _obj.getOrThrow(config, "container", contextName);
|
||||
codec = _obj.getOrThrow(config, "codec", contextName);
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
language = _obj.getOrThrow(config, "language", contextName);
|
||||
duration = _obj.getOrDefault(config, "duration", contextName, null);
|
||||
override val language: String =
|
||||
_obj.getOrThrow<String>(cfg, "language", ctx)
|
||||
|
||||
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
|
||||
override val duration: Long? =
|
||||
_obj.getOrDefault<Long>(cfg, "duration", ctx, null)?.toLong()
|
||||
|
||||
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
|
||||
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
|
||||
}
|
||||
override val name: String =
|
||||
_obj.getOrDefault<String>(cfg, "name", ctx, null)
|
||||
?: "$container $bitrate"
|
||||
|
||||
override fun getAudioUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override var priority: Boolean =
|
||||
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
|
||||
|
||||
override fun toString(): String {
|
||||
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
|
||||
}
|
||||
}
|
||||
override var original: Boolean =
|
||||
if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
|
||||
|
||||
override fun getAudioUrl(): String = url
|
||||
|
||||
override fun toString(): String =
|
||||
"(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"
|
||||
}
|
||||
|
||||
+41
-31
@@ -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;
|
||||
|
||||
+35
-30
@@ -5,42 +5,47 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrNull
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
|
||||
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
|
||||
override val width : Int;
|
||||
override val height : Int;
|
||||
override val container : String;
|
||||
override val codec: String;
|
||||
override val name : String;
|
||||
override val bitrate : Int;
|
||||
override val duration: Long;
|
||||
private val url : String;
|
||||
open class JSVideoUrlSource(
|
||||
plugin: JSClient,
|
||||
obj: V8ValueObject
|
||||
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
|
||||
|
||||
override var priority: Boolean = false;
|
||||
private val ctx = "JSVideoUrlSource"
|
||||
private val cfg = plugin.config
|
||||
|
||||
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
|
||||
val contextName = "JSVideoUrlSource";
|
||||
val config = plugin.config;
|
||||
override val width: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "width", ctx)
|
||||
|
||||
width = _obj.getOrThrow(config, "width", contextName);
|
||||
height = _obj.getOrThrow(config, "height", contextName);
|
||||
container = _obj.getOrThrow(config, "container", contextName);
|
||||
codec = _obj.getOrThrow(config, "codec", contextName);
|
||||
name = _obj.getOrThrow(config, "name", contextName);
|
||||
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
|
||||
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
|
||||
url = _obj.getOrThrow(config, "url", contextName);
|
||||
override val height: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "height", ctx)
|
||||
|
||||
priority = obj.getOrNull(config, "priority", contextName) ?: false;
|
||||
}
|
||||
override val container: String =
|
||||
_obj.getOrThrow<String>(cfg, "container", ctx)
|
||||
|
||||
override fun getVideoUrl() : String {
|
||||
return url;
|
||||
}
|
||||
override val codec: String =
|
||||
_obj.getOrThrow<String>(cfg, "codec", ctx)
|
||||
|
||||
override fun toString(): String {
|
||||
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||
}
|
||||
}
|
||||
override val name: String =
|
||||
_obj.getOrThrow<String>(cfg, "name", ctx)
|
||||
|
||||
override val bitrate: Int =
|
||||
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
|
||||
|
||||
override val duration: Long =
|
||||
_obj.getOrThrow<Long>(cfg, "duration", ctx)
|
||||
|
||||
private val url: String =
|
||||
_obj.getOrThrow<String>(cfg, "url", ctx)
|
||||
|
||||
override var priority: Boolean =
|
||||
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
|
||||
|
||||
override fun getVideoUrl(): String = url
|
||||
|
||||
override fun toString(): String =
|
||||
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
|
||||
}
|
||||
|
||||
+157
-2
@@ -1,5 +1,160 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local
|
||||
|
||||
class LocalClient {
|
||||
//TODO
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformClientCapabilities
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.states.StateLibrary
|
||||
import java.net.MalformedURLException
|
||||
|
||||
class LocalClient: IPlatformClient {
|
||||
override val id: String = "LOCAL"
|
||||
override val name: String = "Local"
|
||||
override val icon: ImageVariable? = ImageVariable.fromResource(R.drawable.ic_library)
|
||||
override val capabilities: PlatformClientCapabilities = PlatformClientCapabilities()
|
||||
|
||||
override fun initialize() {}
|
||||
|
||||
override fun disable() {
|
||||
|
||||
}
|
||||
|
||||
override fun getHome(): IPager<IPlatformContent>
|
||||
= EmptyPager();
|
||||
|
||||
override fun isContentDetailsUrl(url: String): Boolean {
|
||||
try {
|
||||
val uri = Uri.parse(url);
|
||||
return ContentResolver.SCHEME_CONTENT == uri.scheme
|
||||
&& (
|
||||
MediaStore.AUTHORITY == uri.authority ||
|
||||
uri.authority == "com.android.externalstorage.documents"
|
||||
)
|
||||
}
|
||||
catch(ex: MalformedURLException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
val audioExtensions = listOf(".mp3", ".wav", ".flac", ".mp4a", ".m4a");
|
||||
override fun getContentDetails(url: String): IPlatformContentDetails {
|
||||
val uri = Uri.parse(url);
|
||||
|
||||
if("audio" in uri.pathSegments) {
|
||||
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else if("video" in uri.pathSegments) {
|
||||
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else if(uri.toString().contains("com.android.externalstorage.documents")) {
|
||||
if(audioExtensions.any { uri.lastPathSegment?.lowercase()?.endsWith(it) ?: false })
|
||||
return StateLibrary.getAudioTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
else
|
||||
return StateLibrary.getVideoTrack(url) ?: throw Exception("Failed to find ${url}");
|
||||
}
|
||||
else
|
||||
throw Exception("Unknown content url [${url}]");
|
||||
}
|
||||
|
||||
override fun getSearchCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun search(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun getSearchChannelContentsCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun searchChannels(query: String): IPager<PlatformAuthorLink> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun searchChannelsAsContent(query: String): IPager<IPlatformContent> {
|
||||
return EmptyPager(); //TODO
|
||||
}
|
||||
|
||||
override fun isChannelUrl(url: String): Boolean {
|
||||
return false //TODO
|
||||
}
|
||||
|
||||
override fun getChannel(channelUrl: String): IPlatformChannel {
|
||||
throw NotImplementedError();
|
||||
}
|
||||
|
||||
override fun getChannelCapabilities(): ResultCapabilities
|
||||
= ResultCapabilities();
|
||||
override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent> {
|
||||
return EmptyPager();
|
||||
}
|
||||
|
||||
override fun getChannelPlaylists(channelUrl: String): IPager<IPlatformPlaylist> {
|
||||
return EmptyPager();
|
||||
}
|
||||
|
||||
override fun getPeekChannelTypes(): List<String> = listOf();
|
||||
|
||||
override fun peekChannelContents(channelUrl: String, type: String?): List<IPlatformContent>
|
||||
= listOf();
|
||||
|
||||
override fun getShorts(): IPager<IPlatformVideo> = EmptyPager();
|
||||
|
||||
override fun searchSuggestions(query: String): Array<String> = arrayOf();
|
||||
|
||||
override fun getChannelUrlByClaim(claimType: Int, claimValues: Map<Int, String>): String?
|
||||
= null;
|
||||
|
||||
override fun getContentChapters(url: String): List<IChapter>
|
||||
= listOf();
|
||||
|
||||
override fun getPlaybackTracker(url: String): IPlaybackTracker?
|
||||
= null;
|
||||
|
||||
override fun getContentRecommendations(url: String): IPager<IPlatformContent>?
|
||||
= null;
|
||||
|
||||
override fun getComments(url: String): IPager<IPlatformComment>
|
||||
= EmptyPager();
|
||||
|
||||
override fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment>
|
||||
= EmptyPager();
|
||||
|
||||
override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor?
|
||||
= null;
|
||||
|
||||
override fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>?
|
||||
= null;
|
||||
|
||||
override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map<String, List<String>>?): IPager<IPlatformContent>
|
||||
= throw NotImplementedError();
|
||||
|
||||
override fun isPlaylistUrl(url: String): Boolean = false;
|
||||
|
||||
override fun getPlaylist(url: String): IPlatformPlaylistDetails
|
||||
= throw NotImplementedError();
|
||||
override fun getUserPlaylists(): Array<String> = throw NotImplementedError();
|
||||
override fun getUserSubscriptions(): Array<String> = throw NotImplementedError();
|
||||
override fun getUserHistory(): IPager<IPlatformContent> = throw NotImplementedError();
|
||||
override fun isClaimTypeSupported(claimType: Int): Boolean = false;
|
||||
}
|
||||
+14
-4
@@ -1,13 +1,23 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
class LocalVideoMuxedSourceDescriptor(
|
||||
private val video: LocalVideoFileSource
|
||||
) : VideoMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
||||
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
|
||||
constructor(video: LocalVideoFileSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(video: LocalVideoContentSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>) {
|
||||
this.videoSources = videoSources;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN
|
||||
override val original: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioFileSource: IAudioSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN;
|
||||
override val original: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import java.io.File
|
||||
|
||||
class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+3
@@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-7
@@ -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",
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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);
|
||||
|
||||
+159
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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 ?: "";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+617
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -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(", ");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+234
@@ -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>
|
||||
)
|
||||
|
||||
}
|
||||
+297
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+233
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+170
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
+184
@@ -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"
|
||||
|
||||
|
||||
+34
-1
@@ -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);
|
||||
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
+57
-41
@@ -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) {
|
||||
|
||||
+24
-10
@@ -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);
|
||||
|
||||
+129
@@ -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 { }
|
||||
}
|
||||
}
|
||||
+4
@@ -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));
|
||||
}
|
||||
|
||||
+11
-1
@@ -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
Reference in New Issue
Block a user