mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aac19aef86 | |||
| 33efc5c21d | |||
| fc7001c295 | |||
| 9b68394f70 | |||
| e2ef8c2593 | |||
| 551bfe44ac | |||
| 6fbfa98ad3 | |||
| 7d19c2357c | |||
| 64030a038c | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| bce93b8e0f | |||
| 9a950958f9 | |||
| b6676e7763 | |||
| 35fe093e5c | |||
| 7cad4fbe07 | |||
| 240772790d | |||
| d659ecc518 | |||
| 7d8bb20b71 | |||
| 1cf5f776d5 | |||
| 137ba85538 | |||
| 642d218c54 | |||
| 26b5470200 | |||
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 |
+43
-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
|
||||
|
||||
@@ -155,82 +155,80 @@ 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') {
|
||||
|
||||
@@ -153,30 +153,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 +189,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>
|
||||
|
||||
@@ -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,13 @@ 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.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 +23,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
|
||||
@@ -268,12 +269,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(p0?.toException(plugin.config) ?: 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(p0?.toException(plugin.config) ?: 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,13 +301,16 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
||||
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 Exception("Promise Failed: " + pluginType + msg);
|
||||
return Throwable("Promise Failed: " + pluginType + msg);
|
||||
*/
|
||||
}
|
||||
else if(p0 is V8ValueString)
|
||||
return Exception("Promise Failed:" + p0.value);
|
||||
return Throwable("Promise Failed:" + p0.value);
|
||||
else
|
||||
return NotImplementedError("onCatch promise not implemented..");
|
||||
}
|
||||
@@ -358,4 +375,16 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -36,6 +35,7 @@ import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
@@ -199,6 +199,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)
|
||||
@@ -284,9 +285,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 {
|
||||
@@ -301,6 +299,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);
|
||||
@@ -411,6 +412,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 {
|
||||
@@ -639,6 +645,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,
|
||||
@@ -697,17 +708,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) {
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ interface ISubtitleSource {
|
||||
val url: String?;
|
||||
val format: String?;
|
||||
val hasFetch: Boolean;
|
||||
val language: String?
|
||||
|
||||
fun getSubtitles(): String?;
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.awaitCancelConverted
|
||||
import com.futo.platformplayer.builders.DashBuilder
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
@@ -89,8 +90,7 @@ abstract class StateCasting {
|
||||
abstract fun start(context: Context)
|
||||
abstract fun stop()
|
||||
|
||||
@Throws
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice
|
||||
abstract fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice?
|
||||
abstract fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>, setTime: (Long) -> Unit
|
||||
): Job?
|
||||
@@ -1183,7 +1183,7 @@ abstract class StateCasting {
|
||||
onLoading?.invoke(true)
|
||||
}
|
||||
}
|
||||
deferred.await()
|
||||
deferred.awaitCancelConverted()
|
||||
} finally {
|
||||
if (castId == _castId.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -1287,9 +1287,11 @@ abstract class StateCasting {
|
||||
return listOf()
|
||||
}
|
||||
|
||||
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo {
|
||||
val device = deviceFromInfo(deviceInfo);
|
||||
return addRememberedDevice(device);
|
||||
fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo? {
|
||||
return when (val device = deviceFromInfo(deviceInfo)) {
|
||||
null -> null
|
||||
else -> addRememberedDevice(device)
|
||||
}
|
||||
}
|
||||
|
||||
fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo {
|
||||
@@ -1298,7 +1300,7 @@ abstract class StateCasting {
|
||||
}
|
||||
|
||||
fun getRememberedCastingDevices(): List<CastingDevice> {
|
||||
return _storage.getDevices().map { deviceFromInfo(it) }
|
||||
return _storage.getDevices().map { deviceFromInfo(it) }.filterNotNull()
|
||||
}
|
||||
|
||||
fun getRememberedCastingDeviceNames(): List<String> {
|
||||
|
||||
@@ -151,21 +151,25 @@ class StateCastingExp : StateCasting() {
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) } // Throws!
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
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))
|
||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -55,7 +55,9 @@ class StateCastingLegacy : StateCasting() {
|
||||
)
|
||||
)
|
||||
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
if (foundInfo != null) {
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +39,7 @@ import com.futo.platformplayer.engine.packages.PackageHttp
|
||||
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
|
||||
@@ -407,6 +411,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
|
||||
@@ -440,37 +450,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) {
|
||||
@@ -504,7 +483,6 @@ class V8Plugin {
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
||||
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
|
||||
}
|
||||
catch(ex: Exception) {
|
||||
@@ -513,18 +491,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+51
-34
@@ -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
|
||||
@@ -872,11 +883,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.setOnClickListener {
|
||||
handleSeek(_historicalPosition * 1000);
|
||||
|
||||
val job = _jobHideResume;
|
||||
_jobHideResume = null;
|
||||
job?.cancel();
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
@@ -1255,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
|
||||
@@ -1780,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1845,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();
|
||||
@@ -1964,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) {
|
||||
@@ -2648,6 +2660,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
_lastSubtitleSource = toSet;
|
||||
_subtitleLanguage = toSet?.language
|
||||
}
|
||||
|
||||
private fun handleUnavailableVideo(msg: String? = null) {
|
||||
@@ -2805,6 +2818,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_overlay_loading.visibility = View.GONE;
|
||||
(_overlay_loading_spinner.drawable as Animatable?)?.stop()
|
||||
}
|
||||
|
||||
updateResumeVisibilityFor(lastPositionMilliseconds)
|
||||
}
|
||||
|
||||
//UI Actions
|
||||
@@ -3038,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));
|
||||
|
||||
@@ -3095,6 +3110,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
handleSeek(55000);
|
||||
}
|
||||
}
|
||||
|
||||
updateResumeVisibilityFor(positionMilliseconds)
|
||||
}
|
||||
|
||||
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,8 @@ 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.util.*
|
||||
@@ -385,6 +387,29 @@ class StateApp {
|
||||
_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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.futo.platformplayer.views.adapters
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.view.GestureDetector
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -11,6 +16,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.comments.LazyComment
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
@@ -97,6 +103,15 @@ class CommentViewHolder : ViewHolder {
|
||||
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
|
||||
};
|
||||
|
||||
_layoutComment.setOnLongClickListener {
|
||||
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = comment?.message.orEmpty()
|
||||
val clip = ClipData.newPlainText("Comment", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
UIDialogs.toast(viewGroup.context, "Copied", false)
|
||||
true
|
||||
}
|
||||
|
||||
_creatorThumbnail.onClick.subscribe {
|
||||
val c = comment ?: return@subscribe;
|
||||
onAuthorClick.emit(c);
|
||||
@@ -120,7 +135,7 @@ class CommentViewHolder : ViewHolder {
|
||||
onDelete.emit(c);
|
||||
}
|
||||
|
||||
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
|
||||
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context)
|
||||
}
|
||||
|
||||
fun bind(comment: IPlatformComment, readonly: Boolean) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.futo.platformplayer.views.behavior
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
class SafeTextView : AppCompatTextView {
|
||||
constructor(context: Context) : super(context) {}
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {}
|
||||
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {}
|
||||
|
||||
override fun performLongClick(): Boolean {
|
||||
try {
|
||||
return super.performLongClick()
|
||||
} catch (e: IllegalStateException) {
|
||||
Logger.w(TAG, "Swallowed exception", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SafeTextView"
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class CastView : ConstraintLayout {
|
||||
val onPrevious = Event0();
|
||||
val onNext = Event0();
|
||||
val onTimeJobTimeChanged_s = Event1<Long>()
|
||||
val loaderGameVisibilityChanged = Event1<Boolean>();
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
@@ -90,6 +91,7 @@ class CastView : ConstraintLayout {
|
||||
_gestureControlView = findViewById(R.id.gesture_control);
|
||||
_loaderGame = findViewById(R.id.loader_overlay)
|
||||
_loaderGame.visibility = View.GONE
|
||||
loaderGameVisibilityChanged.emit(false)
|
||||
|
||||
_gestureControlView.fullScreenGestureEnabled = false
|
||||
_gestureControlView.setupTouchArea();
|
||||
@@ -319,15 +321,18 @@ class CastView : ConstraintLayout {
|
||||
if (isLoading) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader()
|
||||
loaderGameVisibilityChanged.emit(true)
|
||||
} else {
|
||||
_loaderGame.visibility = View.GONE
|
||||
_loaderGame.stopAndResetLoader()
|
||||
loaderGameVisibilityChanged.emit(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLoading(expectedDurationMs: Int) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
loaderGameVisibilityChanged.emit(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -60,6 +60,10 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
|
||||
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
onPlaybackStateChanged.emit(player.playbackState)
|
||||
}
|
||||
|
||||
if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||
onPlayChanged.emit(player.isPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,6 +164,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
private val _loaderGame: TargetTapLoaderView
|
||||
|
||||
val loaderGameVisibilityChanged = Event1<Boolean>();
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.video_view, this, true);
|
||||
@@ -206,6 +208,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
_loaderGame = findViewById(R.id.loader_overlay)
|
||||
_loaderGame.visibility = View.GONE
|
||||
loaderGameVisibilityChanged.emit(false)
|
||||
|
||||
_control_chapter.setOnClickListener {
|
||||
_currentChapter?.let {
|
||||
@@ -894,15 +897,18 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
if (isLoading) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader()
|
||||
loaderGameVisibilityChanged.emit(true)
|
||||
} else {
|
||||
_loaderGame.visibility = View.GONE
|
||||
_loaderGame.stopAndResetLoader()
|
||||
loaderGameVisibilityChanged.emit(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLoading(expectedDurationMs: Int) {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
loaderGameVisibilityChanged.emit(true)
|
||||
}
|
||||
|
||||
override fun switchToVideoMode() {
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAud
|
||||
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.awaitCancelConverted
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -643,7 +644,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
val generated = generatedDef.awaitCancelConverted();
|
||||
if (_swapIdVideo.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
@@ -807,7 +808,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
val generated = generatedDef.awaitCancelConverted();
|
||||
if (_swapIdAudio.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
@@ -913,6 +914,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage);
|
||||
}
|
||||
|
||||
fun getPreferredSubtitleSource(video: IPlatformVideoDetails, preferredLanguage: String?): ISubtitleSource? {
|
||||
return VideoHelper.selectBestSubtitleSource(video.subtitles, preferredLanguage);
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
|
||||
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/colorPrimary" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
@@ -15,7 +15,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<ImageButton
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<ImageButton
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<ImageButton
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<ImageButton
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
android:id="@+id/layout_buttons"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="20dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_back"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/back"
|
||||
android:padding="12dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:srcCompat="@drawable/ic_back_thin_white_16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_weight="1"
|
||||
android:text="Moderation Settings"
|
||||
android:textColor="?attr/colorOnBackground"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/header">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_moderation_blurb"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="A lower slider value hides more content. Posts or comments with a tag level ABOVE your selected value will be hidden. (Level 0 = most strict, Level 3 = allow everything)"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="?attr/colorSurface"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Moderation Levels"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Offensive Content -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Offensive Content"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_offensive_desc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Description"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekbar_offensive"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="3"
|
||||
android:progress="2" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_offensive_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="@drawable/background_slider_value"
|
||||
android:gravity="center"
|
||||
android:minWidth="36dp"
|
||||
android:padding="8dp"
|
||||
android:text="2"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Explicit Content -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Explicit Content"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_explicit_desc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Description"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekbar_explicit"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="3"
|
||||
android:progress="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_explicit_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="@drawable/background_slider_value"
|
||||
android:gravity="center"
|
||||
android:minWidth="36dp"
|
||||
android:padding="8dp"
|
||||
android:text="1"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Violence -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="Violence"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_violence_desc"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Description"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekbar_violence"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:max="3"
|
||||
android:progress="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_violence_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:background="@drawable/background_slider_value"
|
||||
android:gravity="center"
|
||||
android:minWidth="36dp"
|
||||
android:padding="8dp"
|
||||
android:text="1"
|
||||
android:textColor="@android:color/white"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -73,7 +73,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:text="Further customize your profile, make platform claims, and other creator-specific features in the Harbor app."
|
||||
android:text="Further customize your profile, make platform claims, and other creator-specific features in the Polycentric app."
|
||||
android:textSize="12dp"
|
||||
android:linksClickable="true"
|
||||
android:paddingLeft="20dp"
|
||||
@@ -90,7 +90,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:text="https://harbor.social"
|
||||
android:text="https://polycentric.io"
|
||||
android:textSize="12dp"
|
||||
android:linksClickable="true"
|
||||
android:paddingLeft="20dp"
|
||||
@@ -107,7 +107,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:text="After you've installed Harbor you can export this profile to Harbor using the Export button."
|
||||
android:text="After you've installed Polycentric you can export this profile to Polycentric using the Export button."
|
||||
android:textSize="12dp"
|
||||
android:linksClickable="true"
|
||||
android:paddingLeft="20dp"
|
||||
@@ -133,13 +133,13 @@
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/button_open_harbor_profile"
|
||||
android:id="@+id/button_moderation"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:buttonText="Harbor Profile"
|
||||
app:buttonSubText="See your Harbor profile in a browser"
|
||||
app:buttonIcon="@drawable/ic_export"
|
||||
android:layout_marginTop="8dp" />
|
||||
app:buttonSubText="Set moderation settings for polycentric comments"
|
||||
android:layout_marginTop="8dp"
|
||||
app:buttonIcon="@drawable/ic_settings"
|
||||
app:buttonText="Moderation Settings" />
|
||||
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:id="@+id/button_export"
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="15dp">
|
||||
|
||||
<ImageButton
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/videodetail_root"
|
||||
android:clickable="true">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/topbar"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<TextView
|
||||
<com.futo.platformplayer.views.behavior.SafeTextView
|
||||
android:id="@+id/text_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.FutoVideo.NoActionBarFitsSystem" parent="Theme.FutoVideo.NoActionBar">
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
<item name="android:enforceStatusBarContrast">false</item>
|
||||
<item name="android:enforceNavigationBarContrast">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -505,6 +505,8 @@
|
||||
<string name="preferred_preview_quality">Preferred Preview Quality</string>
|
||||
<string name="preferred_preview_quality_description">Default quality while previewing a video in a feed</string>
|
||||
<string name="primary_language">Primary Language</string>
|
||||
<string name="sticky_subtitles">Persist Subtitles</string>
|
||||
<string name="sticky_subtitles_description">Once a subtitle language is selected, search for a best match once a new video is played</string>
|
||||
<string name="prefer_original_audio">Prefer Original Audio</string>
|
||||
<string name="prefer_original_audio_description">Use original audio instead of preferred language when it is known</string>
|
||||
<string name="default_comment_section">Default Comment Section</string>
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
<item name="colorSecondaryVariant">@color/gray_30</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
|
||||
<item name="android:statusBarColor">@color/black</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
|
||||
<item name="imageButtonStyle">@style/Theme.FutoVideo.ImageButton</item>
|
||||
<item name="editTextStyle">@style/Theme.FutoVideo.EditText</item>
|
||||
@@ -91,6 +92,16 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:windowBackground">@color/black</item>
|
||||
<item name="android:statusBarColor">@color/black</item>
|
||||
<item name="android:navigationBarColor">@color/black</item>
|
||||
<item name="android:navigationBarDividerColor">@color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightNavigationBar">false</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.FutoVideo.NoActionBarFitsSystem" parent="Theme.FutoVideo.NoActionBar">
|
||||
<item name="android:fitsSystemWindows">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.FutoVideo.TextAppearance.TabLayout" parent="@android:style/TextAppearance.Medium">
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id 'com.android.application' version '8.5.2' apply false
|
||||
id 'com.android.library' version '8.5.2' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
|
||||
id 'com.google.protobuf' version '0.9.4' apply false
|
||||
id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false
|
||||
id 'com.android.application' version '8.13.0' apply false
|
||||
id 'com.android.library' version '8.13.0' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.2.21' apply false
|
||||
id 'com.google.protobuf' version '0.9.5' apply false
|
||||
id 'com.google.devtools.ksp' version '2.2.21-2.0.4' apply false
|
||||
}
|
||||
|
||||
+1
-1
Submodule dep/futopay updated: 224d69764c...6857c8f0bc
+1
-1
Submodule dep/polycentricandroid updated: 278e3c2feb...642747fae8
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Fri Nov 11 13:25:09 CET 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in New Issue
Block a user