mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d19c2357c | |||
| 64030a038c | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| bce93b8e0f | |||
| 9a950958f9 | |||
| b6676e7763 | |||
| 35fe093e5c | |||
| 7cad4fbe07 | |||
| 240772790d | |||
| d659ecc518 | |||
| 7d8bb20b71 | |||
| 1cf5f776d5 | |||
| 137ba85538 | |||
| 642d218c54 | |||
| 26b5470200 | |||
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 | |||
| 19429263a9 | |||
| 986652adab | |||
| 4d93a58d5d | |||
| 817c90f3af | |||
| 77348b3787 | |||
| 31e26d03c6 | |||
| 1ef566ab16 | |||
| 7597f5136c | |||
| 9a2a70622f | |||
| 4fc33411fd | |||
| a9bb900994 | |||
| 8c1a18d8b4 | |||
| 14ae5f1572 | |||
| ed40994600 | |||
| 90e8c35b19 | |||
| 4d017ad357 | |||
| 2ca2a9db23 | |||
| 713d46c781 | |||
| 0429665173 | |||
| ac05edca77 | |||
| ad3dacf68f | |||
| 91a8996c11 | |||
| 40c4a51a2b | |||
| f8e0aaf4d2 | |||
| ad97b5a406 | |||
| b0e0c1b75f | |||
| b1fce443e9 | |||
| 66f8711055 | |||
| b7c123c281 | |||
| 9481bbf3f1 | |||
| 43ec7e821b | |||
| ca3454afbe | |||
| 1edc8aabf8 | |||
| 91060faac9 | |||
| 17027ba364 | |||
| 8569eaa5db | |||
| d32d817e0a | |||
| a0f4cc760c | |||
| 5247997ea5 | |||
| 940bed2cee | |||
| 4eb20a1843 | |||
| 98c6378148 | |||
| bb066a7a31 | |||
| b5d3261f03 | |||
| 755bebaecb | |||
| 004e4be4d3 |
+8
-2
@@ -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 35
|
||||
versionCode gitVersionCode
|
||||
versionName gitVersionName
|
||||
|
||||
@@ -231,4 +231,10 @@ dependencies {
|
||||
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'
|
||||
|
||||
//Rust casting SDK
|
||||
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
|
||||
// Polycentricandroid includes this
|
||||
exclude group: 'net.java.dev.jna'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1022,15 +1022,35 @@
|
||||
return x.value
|
||||
});
|
||||
|
||||
|
||||
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||
if (true) {
|
||||
for (let setting of this.Plugin?.currentPlugin?.settings) {
|
||||
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||
switch (setting?.type?.toLowerCase()) {
|
||||
case "boolean":
|
||||
settingsToUse[setting.variable] = setting.default === 'true';
|
||||
break;
|
||||
case "dropdown":
|
||||
let dropDownIndex = parseInt(setting.default);
|
||||
if (dropDownIndex) {
|
||||
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(name == "enable") {
|
||||
if(parameterVals.length > 0)
|
||||
parameterVals[0] = this.Plugin.currentPlugin;
|
||||
else
|
||||
parameterVals.push(this.Plugin.currentPlugin);
|
||||
if(parameterVals.length > 1)
|
||||
parameterVals[1] = __DEV_SETTINGS;
|
||||
parameterVals[1] = settingsToUse;
|
||||
else
|
||||
parameterVals.push(__DEV_SETTINGS);
|
||||
parameterVals.push(settingsToUse);
|
||||
}
|
||||
|
||||
const func = source[name];
|
||||
|
||||
@@ -67,6 +67,7 @@ class ScriptException extends Error {
|
||||
super(arguments[0]);
|
||||
this.plugin_type = "ScriptException";
|
||||
this.message = arguments[0];
|
||||
this.msg = arguments[0];
|
||||
}
|
||||
else {
|
||||
super(msg);
|
||||
|
||||
@@ -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
|
||||
@@ -194,7 +195,6 @@ fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
val latch = CountDownLatch(1);
|
||||
var promiseResult: T? = null;
|
||||
@@ -204,16 +204,19 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
override fun onFulfilled(p0: V8Value?) {
|
||||
if(p0 is V8ValueError)
|
||||
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||
else
|
||||
else {
|
||||
if(p0 is V8ValueObject)
|
||||
p0.setWeak();
|
||||
promiseResult = p0 as T;
|
||||
}
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onRejected(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onRejected promise not implemented.."));
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
promiseException = (NotImplementedError("onCatch promise not implemented.."));
|
||||
promiseException = p0?.toException(plugin.config);
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
@@ -223,8 +226,25 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||
promiseException = CancellationException("Cancelled by system");
|
||||
latch.countDown();
|
||||
}
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
||||
|
||||
|
||||
if(!promise.isPending) {
|
||||
try {
|
||||
Logger.i("V8", "V8Promise resolved synchronously");
|
||||
if(promise.isFulfilled)
|
||||
promiseResult = promise.getResult<T>();
|
||||
else
|
||||
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
promiseException = ex;
|
||||
}
|
||||
}
|
||||
else {
|
||||
plugin.unbusy {
|
||||
latch.await();
|
||||
}
|
||||
}
|
||||
if(promiseException != null)
|
||||
throw promiseException!!;
|
||||
@@ -249,12 +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(NotImplementedError("onRejected promise not implemented.."));
|
||||
try {
|
||||
plugin.resolvePromise(promise);
|
||||
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
|
||||
Logger.i("V8", "Promise rejected, setting exception");
|
||||
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("V8", "Rejection handling failed?" , ex);
|
||||
}
|
||||
}
|
||||
override fun onCatch(p0: V8Value?) {
|
||||
plugin.resolvePromise(promise);
|
||||
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
|
||||
try {
|
||||
plugin.resolvePromise(promise);
|
||||
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
|
||||
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e("V8", "Catching handling failed?" , ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -265,6 +298,23 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
|
||||
return def;
|
||||
}
|
||||
|
||||
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||
val p0 = this;
|
||||
if(p0 is V8ValueObject) {
|
||||
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
|
||||
/*
|
||||
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
||||
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||
return Throwable("Promise Failed: " + pluginType + msg);
|
||||
*/
|
||||
}
|
||||
else if(p0 is V8ValueString)
|
||||
return Throwable("Promise Failed:" + p0.value);
|
||||
else
|
||||
return NotImplementedError("onCatch promise not implemented..");
|
||||
}
|
||||
|
||||
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||
|
||||
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||
@@ -325,4 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import com.futo.platformplayer.states.StateCache
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
import com.futo.platformplayer.states.StatePayment
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSync
|
||||
import com.futo.platformplayer.states.StateUpdate
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||
@@ -34,6 +35,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import com.futo.platformplayer.views.fields.FormFieldButton
|
||||
import com.futo.platformplayer.views.fields.FormFieldWarning
|
||||
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -201,6 +203,8 @@ class Settings : FragmentedStorageFileJson() {
|
||||
8 -> "zh";
|
||||
9 -> "ru";
|
||||
10 -> "ar";
|
||||
11 -> "it";
|
||||
12 -> "tr";
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -608,6 +612,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||
var shortsPregenerate: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
|
||||
@FormFieldWarning(R.string.shorts_fit_video_warning)
|
||||
var shortsFitVideo: Boolean = false;
|
||||
}
|
||||
|
||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||
@@ -710,6 +719,11 @@ class Settings : FragmentedStorageFileJson() {
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var allowLinkLocalIpv4: Boolean = false;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.experimental_cast, FieldForm.TOGGLE, R.string.experimental_cast_description, 6)
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
var experimentalCasting: Boolean = false
|
||||
|
||||
/*TODO: Should we have a different casting quality?
|
||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||
@@ -1092,6 +1106,39 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||
var localConnections: Boolean = true;
|
||||
|
||||
|
||||
|
||||
var syncServerUrl: String? = null;
|
||||
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
|
||||
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
|
||||
|
||||
@AdvancedField
|
||||
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||
fun configureSyncServer() {
|
||||
SettingsActivity.getActivity()?.let { context ->
|
||||
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||
"Enter the url to your relay server",
|
||||
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||
null,
|
||||
syncServerUrl ?: "",
|
||||
"YourRelayServerDomain.com", 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Reset", {
|
||||
syncServerUrl = null;
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.ACCENT),
|
||||
UIDialogs.Action.withInput("Configure", {
|
||||
syncServerUrl = it?.text
|
||||
instance.save();
|
||||
context.reloadSettings();
|
||||
UIDialogs.toast("Sync server changes require a restart");
|
||||
}, UIDialogs.ActionStyle.PRIMARY),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||
|
||||
@@ -113,8 +113,8 @@ class UIDialogs {
|
||||
currentDialog.code,
|
||||
currentDialog.defaultCloseAction,
|
||||
*currentDialog.actions.map {
|
||||
return@map Action(it.text, {
|
||||
it.action();
|
||||
return@map Action.withInput(it.text, { str ->
|
||||
it.invokeAction(str);
|
||||
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
||||
}, it.style);
|
||||
}.toTypedArray());
|
||||
@@ -203,7 +203,9 @@ class UIDialogs {
|
||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||
}
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
|
||||
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
|
||||
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||
val builder = AlertDialog.Builder(context);
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||
builder.setView(view);
|
||||
@@ -226,6 +228,16 @@ class UIDialogs {
|
||||
this.text = textDetails;
|
||||
}
|
||||
};
|
||||
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
|
||||
inputView.apply {
|
||||
if (input == null && placeholder == null) this.visibility = View.GONE;
|
||||
else {
|
||||
this.text = input ?: "";
|
||||
this.hint = placeholder ?: "";
|
||||
this.visibility = View.VISIBLE;
|
||||
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||
}
|
||||
};
|
||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||
if (code == null) this.visibility = View.GONE;
|
||||
else {
|
||||
@@ -250,7 +262,7 @@ class UIDialogs {
|
||||
buttonView.textSize = 14f;
|
||||
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
||||
buttonView.text = act.text;
|
||||
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
|
||||
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
|
||||
when(act.style) {
|
||||
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
||||
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
||||
@@ -275,7 +287,7 @@ class UIDialogs {
|
||||
};
|
||||
dialog.setOnCancelListener {
|
||||
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
||||
actions[defaultCloseAction].action();
|
||||
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
registerDialogClosed(dialog);
|
||||
@@ -535,17 +547,36 @@ class UIDialogs {
|
||||
}
|
||||
class Action {
|
||||
val text: String;
|
||||
val action: ()->Unit;
|
||||
val action: ((DialogResult?)->Unit);
|
||||
val style: ActionStyle;
|
||||
var center: Boolean;
|
||||
|
||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = { action() };
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||
this.text = text;
|
||||
this.action = action;
|
||||
this.style = style;
|
||||
this.center = center;
|
||||
}
|
||||
|
||||
fun invokeAction(input: DialogResult? = null) {
|
||||
this.action(input);
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
|
||||
return Action(text, action, style, center);
|
||||
}
|
||||
}
|
||||
}
|
||||
class DialogResult(
|
||||
val text: String?
|
||||
);
|
||||
enum class ActionStyle {
|
||||
NONE,
|
||||
PRIMARY,
|
||||
|
||||
@@ -76,9 +76,25 @@ class LoginActivity : AppCompatActivity() {
|
||||
};
|
||||
var isFirstLoad = true;
|
||||
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||
var currentScale = 100;
|
||||
var currentDesktop = false;
|
||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||
_textUrl.setText(url ?: "");
|
||||
|
||||
if(loginWarnings.size > 0 && url != null) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!isFirstLoad)
|
||||
return@subscribe;
|
||||
isFirstLoad = false;
|
||||
@@ -89,18 +105,34 @@ class LoginActivity : AppCompatActivity() {
|
||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||
}
|
||||
|
||||
if(loginWarnings.size > 0) {
|
||||
synchronized(loginWarnings) {
|
||||
val warning = loginWarnings.find { it.url.matches(it.getRegex()) };
|
||||
if(warning != null) {
|
||||
if(warning.once == true)
|
||||
loginWarnings.remove(warning);
|
||||
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||
UIDialogs.Action("Understood", {
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
/*
|
||||
var specifiedScale = false;
|
||||
var specifiedDesktop = false;
|
||||
if(uiMods.size > 0 && url != null) {
|
||||
synchronized(uiMods) {
|
||||
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||
if(uimod != null) {
|
||||
if(uimod.scale != null) {
|
||||
currentScale =(uimod.scale * 100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
specifiedScale = true;
|
||||
}
|
||||
if(uimod.desktop != null && uimod.desktop) {
|
||||
_webView.settings.useWideViewPort = true;
|
||||
specifiedDesktop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!specifiedScale && currentScale != 100) {
|
||||
currentScale = (100).toInt();
|
||||
_webView.setInitialScale(currentScale);
|
||||
}
|
||||
if(!specifiedDesktop && currentDesktop) {
|
||||
_webView.settings.useWideViewPort = false;
|
||||
currentDesktop = false;
|
||||
}
|
||||
*/
|
||||
}
|
||||
_webView.settings.domStorageEnabled = true;
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.result.ActivityResult
|
||||
@@ -36,9 +35,11 @@ import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.RootInsetsController
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.video.LocalVideoDetails
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
@@ -198,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)
|
||||
@@ -283,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 {
|
||||
@@ -300,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);
|
||||
@@ -410,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 {
|
||||
@@ -638,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,
|
||||
@@ -768,7 +780,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
if (targetData != null) {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
handleUrlAll(targetData, intent)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
@@ -779,8 +791,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleUrlAll(url: String) {
|
||||
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
|
||||
val uri = Uri.parse(url)
|
||||
val intent = openIntent ?: this.intent;
|
||||
when (uri.scheme) {
|
||||
"grayjay" -> {
|
||||
if (url.startsWith("grayjay://license/")) {
|
||||
@@ -807,11 +820,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
|
||||
"content" -> {
|
||||
if (!handleContent(url, intent.type)) {
|
||||
if (!handleContent(url, intent?.type)) {
|
||||
UIDialogs.showSingleButtonDialog(
|
||||
this,
|
||||
R.drawable.ic_play,
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent.type}]",
|
||||
getString(R.string.unknown_content_format) + " [${url}]\n[${intent?.type}]",
|
||||
"Ok",
|
||||
{ });
|
||||
}
|
||||
@@ -932,6 +945,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
} else if (file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
else if (mime?.let { it.startsWith("video/") || it.startsWith("audio/") } ?: false) {
|
||||
val mediaItem = LocalVideoDetails.fromContent(file, mime);
|
||||
navigateWhenReady(_fragVideoDetail, mediaItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1046,7 +1065,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
Logger.i(TAG, "handleFCast");
|
||||
|
||||
try {
|
||||
StateCasting.instance.handleUrl(this, url)
|
||||
StateCasting.instance.handleUrl(url)
|
||||
return true;
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
|
||||
|
||||
+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) {
|
||||
|
||||
+17
-3
@@ -2,10 +2,24 @@ package com.futo.platformplayer.api.media.models.streams
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
|
||||
class LocalVideoUnMuxedSourceDescriptor(private val video: VideoLocal) : VideoUnMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = video.videoSource.toTypedArray();
|
||||
override val audioSources: Array<IAudioSource> get() = video.audioSource.toTypedArray();
|
||||
class LocalVideoUnMuxedSourceDescriptor : VideoUnMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
override val audioSources: Array<IAudioSource>;
|
||||
|
||||
constructor(video: VideoLocal) {
|
||||
videoSources = video.videoSource.toTypedArray();
|
||||
audioSources = video.audioSource.toTypedArray();
|
||||
}
|
||||
constructor(audio: LocalAudioContentSource) {
|
||||
videoSources = arrayOf()
|
||||
audioSources = arrayOf(audio);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>, audioSources: Array<IAudioSource>) {
|
||||
this.videoSources = videoSources;
|
||||
this.audioSources = audioSources;
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -14,7 +14,8 @@ class AudioUrlSource(
|
||||
override val language: String = Language.UNKNOWN,
|
||||
override val duration: Long? = null,
|
||||
override var priority: Boolean = false,
|
||||
override var original: Boolean = false
|
||||
override var original: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IAudioUrlSource, IStreamMetaDataSource{
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@ open class VideoUrlSource(
|
||||
override val codec : String = "",
|
||||
override val bitrate : Int? = 0,
|
||||
|
||||
override var priority: Boolean = false
|
||||
override var priority: Boolean = false,
|
||||
var isLocal: Boolean = false
|
||||
) : IVideoUrlSource, IStreamMetaDataSource {
|
||||
override var streamMetaData: StreamMetaData? = null;
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.futo.platformplayer.api.media.models.video
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.net.toUri
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.Serializer
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.others.Language
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
open class LocalVideoDetails(
|
||||
override val id: PlatformID,
|
||||
override val name: String,
|
||||
override val thumbnails: Thumbnails,
|
||||
override val author: PlatformAuthorLink,
|
||||
override val url: String,
|
||||
override val duration: Long,
|
||||
|
||||
val mimeType: String? = null,
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override val datetime: OffsetDateTime?
|
||||
) : IPlatformVideo, IPlatformVideoDetails {
|
||||
final override val contentType: ContentType get() = ContentType.MEDIA;
|
||||
|
||||
override var playbackTime: Long = -1;
|
||||
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
|
||||
override var playbackDate: OffsetDateTime? = null;
|
||||
|
||||
override val isLive: Boolean get() = false;
|
||||
|
||||
override val dash: IDashManifestSource? get() = null;
|
||||
override val hls: IHLSManifestSource? get() = null;
|
||||
override val live: IVideoSource? get() = null;
|
||||
|
||||
|
||||
override val shareUrl: String = ""
|
||||
override val viewCount: Long = -1
|
||||
override val rating: IRating = RatingLikes(0)
|
||||
override val description: String = "";
|
||||
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
|
||||
(LocalVideoUnMuxedSourceDescriptor(
|
||||
arrayOf(),
|
||||
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
|
||||
))
|
||||
else (LocalVideoMuxedSourceDescriptor(
|
||||
LocalVideoContentSource(url, mimeType ?: "", name)
|
||||
))
|
||||
);
|
||||
override val preview: ISerializedVideoSourceDescriptor? = null;
|
||||
|
||||
override val subtitles: List<SubtitleRawSource> = listOf()
|
||||
override val isShort: Boolean = false
|
||||
|
||||
fun toJson() : String {
|
||||
return Json.encodeToString(this);
|
||||
}
|
||||
fun fromJson(str : String) : SerializedPlatformVideoDetails {
|
||||
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
companion object {
|
||||
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
if(filePath.startsWith("content://"))
|
||||
return fromContent(filePath, mimeType);
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
|
||||
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
|
||||
}
|
||||
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
|
||||
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
|
||||
|
||||
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
|
||||
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getFileNameFromContentUrl(url: String): String? {
|
||||
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
|
||||
cursor?.moveToFirst();
|
||||
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
|
||||
cursor?.close();
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -16,7 +16,8 @@ class SourcePluginAuthConfig(
|
||||
val loginButton: String? = null,
|
||||
val domainHeadersToFind: Map<String, List<String>>? = null,
|
||||
val loginWarning: String? = null,
|
||||
val loginWarnings: List<Warning>? = null
|
||||
val loginWarnings: List<Warning>? = null,
|
||||
val uiMods: List<UIMod>? = null
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
@@ -29,6 +30,23 @@ class SourcePluginAuthConfig(
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
_regex = reg;
|
||||
return reg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@Serializable
|
||||
class UIMod(
|
||||
val url: String,
|
||||
val scale: Float?,
|
||||
val desktop: Boolean?
|
||||
) {
|
||||
@Contextual
|
||||
private var _regex: Regex? = null;
|
||||
|
||||
fun getRegex(): Regex {
|
||||
return _regex ?: url.let {
|
||||
val reg = Regex(it);
|
||||
|
||||
+14
-4
@@ -1,13 +1,23 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models
|
||||
|
||||
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
|
||||
import com.futo.platformplayer.downloads.VideoLocal
|
||||
|
||||
class LocalVideoMuxedSourceDescriptor(
|
||||
private val video: LocalVideoFileSource
|
||||
) : VideoMuxedSourceDescriptor() {
|
||||
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
|
||||
class LocalVideoMuxedSourceDescriptor: VideoMuxedSourceDescriptor {
|
||||
override val videoSources: Array<IVideoSource>;
|
||||
|
||||
constructor(video: LocalVideoFileSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(video: LocalVideoContentSource) {
|
||||
videoSources = arrayOf(video);
|
||||
}
|
||||
constructor(videoSources: Array<IVideoSource>) {
|
||||
this.videoSources = videoSources;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioContentSource : IAudioSource {
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN
|
||||
override val original: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
container = mime;
|
||||
duration = 0;
|
||||
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.others.Language
|
||||
import java.io.File
|
||||
|
||||
class LocalAudioFileSource: IAudioSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
override val language: String = Language.UNKNOWN;
|
||||
override val original: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
container = VideoHelper.videoExtensionToMimetype(file.extension) ?: "";
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.futo.platformplayer.api.media.platforms.local.models.sources
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Video
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
|
||||
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import java.io.File
|
||||
|
||||
class LocalVideoContentSource: IVideoSource {
|
||||
|
||||
|
||||
override val name: String;
|
||||
override val width: Int;
|
||||
override val height: Int;
|
||||
override val container: String;
|
||||
override val codec: String = ""
|
||||
override val bitrate: Int = 0
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var contentUrl: String;
|
||||
|
||||
constructor(contentUrl: String, mime: String, name: String? = null) {
|
||||
this.name = name ?: "File";
|
||||
width = 0;
|
||||
height = 0;
|
||||
container = mime;
|
||||
duration = 0;
|
||||
this.contentUrl = contentUrl;
|
||||
}
|
||||
}
|
||||
+3
@@ -20,7 +20,10 @@ class LocalVideoFileSource: IVideoSource {
|
||||
override val duration: Long;
|
||||
override val priority: Boolean = false;
|
||||
|
||||
var file: File;
|
||||
|
||||
constructor(file: File) {
|
||||
this.file = file;
|
||||
name = file.name;
|
||||
width = 0;
|
||||
height = 0;
|
||||
|
||||
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.UUID
|
||||
|
||||
class AirPlayCastingDevice : CastingDevice {
|
||||
class AirPlayCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://nto.github.io/AirPlay
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
|
||||
|
||||
@@ -2,147 +2,78 @@ package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDevice {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
abstract val isReady: Boolean
|
||||
abstract val usedRemoteAddress: InetAddress?
|
||||
abstract val localAddress: InetAddress?
|
||||
abstract val name: String?
|
||||
abstract val onConnectionStateChanged: Event1<CastConnectionState>
|
||||
abstract val onPlayChanged: Event1<Boolean>
|
||||
abstract val onTimeChanged: Event1<Double>
|
||||
abstract val onDurationChanged: Event1<Double>
|
||||
abstract val onVolumeChanged: Event1<Double>
|
||||
abstract val onSpeedChanged: Event1<Double>
|
||||
abstract var connectionState: CastConnectionState
|
||||
abstract val protocolType: CastProtocolType
|
||||
abstract var isPlaying: Boolean
|
||||
abstract val expectedCurrentTime: Double
|
||||
abstract var speed: Double
|
||||
abstract var time: Double
|
||||
abstract var duration: Double
|
||||
abstract var volume: Double
|
||||
abstract fun canSetVolume(): Boolean
|
||||
abstract fun canSetSpeed(): Boolean
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
@Throws
|
||||
abstract fun resumePlayback()
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun pausePlayback()
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun stopPlayback()
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun seekTo(timeSeconds: Double)
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun changeVolume(timeSeconds: Double)
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun changeSpeed(speed: Double)
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun connect()
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
@Throws
|
||||
abstract fun disconnect()
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo
|
||||
abstract fun getAddresses(): List<InetAddress>
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
@Throws
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff = if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
@Throws
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
)
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
abstract fun ensureThreadStarted()
|
||||
}
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
abstract fun stopCasting();
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(streamType: String, contentType: String, contentId: String, resumePosition: Double, duration: Double, speed: Double?);
|
||||
abstract fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?);
|
||||
open fun changeVolume(volume: Double) { throw NotImplementedError() }
|
||||
open fun changeSpeed(speed: Double) { throw NotImplementedError() }
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.os.Build
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import org.fcast.sender_sdk.ApplicationInfo
|
||||
import org.fcast.sender_sdk.GenericKeyEvent
|
||||
import org.fcast.sender_sdk.GenericMediaEvent
|
||||
import org.fcast.sender_sdk.PlaybackState
|
||||
import org.fcast.sender_sdk.Source
|
||||
import java.net.InetAddress
|
||||
import org.fcast.sender_sdk.CastingDevice as RsCastingDevice;
|
||||
import org.fcast.sender_sdk.DeviceEventHandler as RsDeviceEventHandler;
|
||||
import org.fcast.sender_sdk.DeviceConnectionState
|
||||
import org.fcast.sender_sdk.DeviceFeature
|
||||
import org.fcast.sender_sdk.IpAddr
|
||||
import org.fcast.sender_sdk.LoadRequest
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.urlFormatIpAddr
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
|
||||
private fun ipAddrToInetAddress(addr: IpAddr): InetAddress = when (addr) {
|
||||
is IpAddr.V4 -> Inet4Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte()
|
||||
)
|
||||
)
|
||||
|
||||
is IpAddr.V6 -> Inet6Address.getByAddress(
|
||||
byteArrayOf(
|
||||
addr.o1.toByte(),
|
||||
addr.o2.toByte(),
|
||||
addr.o3.toByte(),
|
||||
addr.o4.toByte(),
|
||||
addr.o5.toByte(),
|
||||
addr.o6.toByte(),
|
||||
addr.o7.toByte(),
|
||||
addr.o8.toByte(),
|
||||
addr.o9.toByte(),
|
||||
addr.o10.toByte(),
|
||||
addr.o11.toByte(),
|
||||
addr.o12.toByte(),
|
||||
addr.o13.toByte(),
|
||||
addr.o14.toByte(),
|
||||
addr.o15.toByte(),
|
||||
addr.o16.toByte()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class CastingDeviceExp(val device: RsCastingDevice) : CastingDevice() {
|
||||
class EventHandler : RsDeviceEventHandler {
|
||||
var onConnectionStateChanged = Event1<DeviceConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>()
|
||||
var onTimeChanged = Event1<Double>()
|
||||
var onDurationChanged = Event1<Double>()
|
||||
var onVolumeChanged = Event1<Double>()
|
||||
var onSpeedChanged = Event1<Double>()
|
||||
|
||||
override fun connectionStateChanged(state: DeviceConnectionState) {
|
||||
onConnectionStateChanged.emit(state)
|
||||
}
|
||||
|
||||
override fun volumeChanged(volume: Double) {
|
||||
onVolumeChanged.emit(volume)
|
||||
}
|
||||
|
||||
override fun timeChanged(time: Double) {
|
||||
onTimeChanged.emit(time)
|
||||
}
|
||||
|
||||
override fun playbackStateChanged(state: PlaybackState) {
|
||||
onPlayChanged.emit(state == PlaybackState.PLAYING)
|
||||
}
|
||||
|
||||
override fun durationChanged(duration: Double) {
|
||||
onDurationChanged.emit(duration)
|
||||
}
|
||||
|
||||
override fun speedChanged(speed: Double) {
|
||||
onSpeedChanged.emit(speed)
|
||||
}
|
||||
|
||||
override fun sourceChanged(source: Source) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
override fun keyEvent(event: GenericKeyEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun mediaEvent(event: GenericMediaEvent) {
|
||||
// Unreachable
|
||||
}
|
||||
|
||||
override fun playbackError(message: String) {
|
||||
Logger.e(TAG, "Playback error: $message")
|
||||
}
|
||||
}
|
||||
|
||||
val eventHandler = EventHandler()
|
||||
override val isReady: Boolean
|
||||
get() = device.isReady()
|
||||
override val name: String
|
||||
get() = device.name()
|
||||
override var usedRemoteAddress: InetAddress? = null
|
||||
override var localAddress: InetAddress? = null
|
||||
override fun canSetVolume(): Boolean = device.supportsFeature(DeviceFeature.SET_VOLUME)
|
||||
override fun canSetSpeed(): Boolean = device.supportsFeature(DeviceFeature.SET_SPEED)
|
||||
|
||||
override val onConnectionStateChanged =
|
||||
Event1<CastConnectionState>()
|
||||
override val onPlayChanged: Event1<Boolean>
|
||||
get() = eventHandler.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double>
|
||||
get() = eventHandler.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double>
|
||||
get() = eventHandler.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double>
|
||||
get() = eventHandler.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double>
|
||||
get() = eventHandler.onSpeedChanged
|
||||
|
||||
override fun resumePlayback() = device.resumePlayback()
|
||||
override fun pausePlayback() = device.pausePlayback()
|
||||
override fun stopPlayback() = device.stopPlayback()
|
||||
override fun seekTo(timeSeconds: Double) = device.seek(timeSeconds)
|
||||
override fun changeVolume(newVolume: Double) {
|
||||
device.changeVolume(newVolume)
|
||||
volume = newVolume
|
||||
}
|
||||
override fun changeSpeed(speed: Double) = device.changeSpeed(speed)
|
||||
override fun connect() = device.connect(
|
||||
ApplicationInfo(
|
||||
"Grayjay Android",
|
||||
"${BuildConfig.VERSION_NAME}-${BuildConfig.FLAVOR}",
|
||||
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
),
|
||||
eventHandler,
|
||||
1000.toULong()
|
||||
)
|
||||
|
||||
override fun disconnect() = device.disconnect()
|
||||
|
||||
override fun getDeviceInfo(): CastingDeviceInfo {
|
||||
val info = device.getDeviceInfo()
|
||||
return CastingDeviceInfo(
|
||||
info.name,
|
||||
when (info.protocol) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
},
|
||||
addresses = info.addresses.map { urlFormatIpAddr(it) }.toTypedArray(),
|
||||
port = info.port.toInt(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAddresses(): List<InetAddress> = device.getAddresses().map {
|
||||
ipAddrToInetAddress(it)
|
||||
}
|
||||
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Video(
|
||||
contentType = contentType,
|
||||
url = contentId,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata
|
||||
)
|
||||
)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = device.load(
|
||||
LoadRequest.Content(
|
||||
contentType = contentType,
|
||||
content = content,
|
||||
resumePosition = resumePosition,
|
||||
speed = speed,
|
||||
volume = volume,
|
||||
metadata = metadata,
|
||||
)
|
||||
)
|
||||
|
||||
override var connectionState = CastConnectionState.DISCONNECTED
|
||||
override val protocolType: CastProtocolType
|
||||
get() = when (device.castingProtocol()) {
|
||||
ProtocolType.CHROMECAST -> CastProtocolType.CHROMECAST
|
||||
ProtocolType.F_CAST -> CastProtocolType.FCAST
|
||||
}
|
||||
override var volume: Double = 1.0
|
||||
override var duration: Double = 0.0
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
override var time: Double = 0.0
|
||||
override var speed: Double = 0.0
|
||||
override var isPlaying: Boolean = false
|
||||
|
||||
override val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff
|
||||
}
|
||||
|
||||
init {
|
||||
eventHandler.onConnectionStateChanged.subscribe { newState ->
|
||||
when (newState) {
|
||||
is DeviceConnectionState.Connected -> {
|
||||
usedRemoteAddress = ipAddrToInetAddress(newState.usedRemoteAddr)
|
||||
localAddress = ipAddrToInetAddress(newState.localAddr)
|
||||
connectionState = CastConnectionState.CONNECTED
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTED)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Connecting, DeviceConnectionState.Reconnecting -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.CONNECTING)
|
||||
}
|
||||
|
||||
DeviceConnectionState.Disconnected -> {
|
||||
connectionState = CastConnectionState.CONNECTING
|
||||
onConnectionStateChanged.emit(CastConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
if (newState == DeviceConnectionState.Disconnected) {
|
||||
try {
|
||||
Logger.i(TAG, "Stopping device")
|
||||
device.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to stop device: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
eventHandler.onPlayChanged.subscribe { isPlaying = it }
|
||||
eventHandler.onTimeChanged.subscribe {
|
||||
lastTimeChangeTime_ms = System.currentTimeMillis()
|
||||
time = it
|
||||
}
|
||||
eventHandler.onDurationChanged.subscribe { duration = it }
|
||||
eventHandler.onVolumeChanged.subscribe { volume = it }
|
||||
eventHandler.onSpeedChanged.subscribe { speed = it }
|
||||
}
|
||||
|
||||
override fun ensureThreadStarted() {}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastingDeviceExp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.fcast.sender_sdk.Metadata
|
||||
import java.net.InetAddress
|
||||
|
||||
enum class CastConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED
|
||||
}
|
||||
|
||||
@Serializable(with = CastProtocolType.CastProtocolTypeSerializer::class)
|
||||
enum class CastProtocolType {
|
||||
CHROMECAST,
|
||||
AIRPLAY,
|
||||
FCAST;
|
||||
|
||||
object CastProtocolTypeSerializer : KSerializer<CastProtocolType> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("CastProtocolType", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: CastProtocolType) {
|
||||
encoder.encodeString(value.name)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): CastProtocolType {
|
||||
val name = decoder.decodeString()
|
||||
return when (name) {
|
||||
"FASTCAST" -> FCAST // Handle the renamed case
|
||||
else -> CastProtocolType.valueOf(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CastingDeviceLegacy {
|
||||
abstract val protocol: CastProtocolType;
|
||||
abstract val isReady: Boolean;
|
||||
abstract var usedRemoteAddress: InetAddress?;
|
||||
abstract var localAddress: InetAddress?;
|
||||
abstract val canSetVolume: Boolean;
|
||||
abstract val canSetSpeed: Boolean;
|
||||
|
||||
var name: String? = null;
|
||||
var isPlaying: Boolean = false
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
if (changed) {
|
||||
onPlayChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
private var lastTimeChangeTime_ms: Long = 0
|
||||
var time: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setTime(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastTimeChangeTime_ms && value != time) {
|
||||
time = value
|
||||
lastTimeChangeTime_ms = changeTime_ms
|
||||
onTimeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastDurationChangeTime_ms: Long = 0
|
||||
var duration: Double = 0.0
|
||||
private set
|
||||
|
||||
protected fun setDuration(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastDurationChangeTime_ms && value != duration) {
|
||||
duration = value
|
||||
lastDurationChangeTime_ms = changeTime_ms
|
||||
onDurationChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastVolumeChangeTime_ms: Long = 0
|
||||
var volume: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setVolume(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastVolumeChangeTime_ms && value != volume) {
|
||||
volume = value
|
||||
lastVolumeChangeTime_ms = changeTime_ms
|
||||
onVolumeChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSpeedChangeTime_ms: Long = 0
|
||||
var speed: Double = 1.0
|
||||
private set
|
||||
|
||||
protected fun setSpeed(value: Double, changeTime_ms: Long = System.currentTimeMillis()) {
|
||||
if (changeTime_ms > lastSpeedChangeTime_ms && value != speed) {
|
||||
speed = value
|
||||
lastSpeedChangeTime_ms = changeTime_ms
|
||||
onSpeedChanged.emit(value)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedCurrentTime: Double
|
||||
get() {
|
||||
val diff =
|
||||
if (isPlaying) ((System.currentTimeMillis() - lastTimeChangeTime_ms).toDouble() / 1000.0) else 0.0;
|
||||
return time + diff;
|
||||
};
|
||||
var connectionState: CastConnectionState = CastConnectionState.DISCONNECTED
|
||||
set(value) {
|
||||
val changed = value != field;
|
||||
field = value;
|
||||
|
||||
if (changed) {
|
||||
onConnectionStateChanged.emit(value);
|
||||
}
|
||||
};
|
||||
|
||||
var onConnectionStateChanged = Event1<CastConnectionState>();
|
||||
var onPlayChanged = Event1<Boolean>();
|
||||
var onTimeChanged = Event1<Double>();
|
||||
var onDurationChanged = Event1<Double>();
|
||||
var onVolumeChanged = Event1<Double>();
|
||||
var onSpeedChanged = Event1<Double>();
|
||||
|
||||
abstract fun stopCasting();
|
||||
|
||||
abstract fun seekVideo(timeSeconds: Double);
|
||||
abstract fun stopVideo();
|
||||
abstract fun pauseVideo();
|
||||
abstract fun resumeVideo();
|
||||
abstract fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
abstract fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?
|
||||
);
|
||||
|
||||
open fun changeVolume(volume: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun changeSpeed(speed: Double) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
abstract fun start();
|
||||
abstract fun stop();
|
||||
|
||||
abstract fun getDeviceInfo(): CastingDeviceInfo;
|
||||
|
||||
abstract fun getAddresses(): List<InetAddress>;
|
||||
}
|
||||
|
||||
class CastingDeviceLegacyWrapper(val inner: CastingDeviceLegacy) : CastingDevice() {
|
||||
override val isReady: Boolean get() = inner.isReady
|
||||
override val usedRemoteAddress: InetAddress? get() = inner.usedRemoteAddress
|
||||
override val localAddress: InetAddress? get() = inner.localAddress
|
||||
override val name: String? get() = inner.name
|
||||
override val onConnectionStateChanged: Event1<CastConnectionState> get() = inner.onConnectionStateChanged
|
||||
override val onPlayChanged: Event1<Boolean> get() = inner.onPlayChanged
|
||||
override val onTimeChanged: Event1<Double> get() = inner.onTimeChanged
|
||||
override val onDurationChanged: Event1<Double> get() = inner.onDurationChanged
|
||||
override val onVolumeChanged: Event1<Double> get() = inner.onVolumeChanged
|
||||
override val onSpeedChanged: Event1<Double> get() = inner.onSpeedChanged
|
||||
override var connectionState: CastConnectionState
|
||||
get() = inner.connectionState
|
||||
set(_) = Unit
|
||||
override val protocolType: CastProtocolType get() = inner.protocol
|
||||
override var isPlaying: Boolean
|
||||
get() = inner.isPlaying
|
||||
set(_) = Unit
|
||||
override val expectedCurrentTime: Double
|
||||
get() = inner.expectedCurrentTime
|
||||
override var speed: Double
|
||||
get() = inner.speed
|
||||
set(_) = Unit
|
||||
override var time: Double
|
||||
get() = inner.time
|
||||
set(_) = Unit
|
||||
override var duration: Double
|
||||
get() = inner.duration
|
||||
set(_) = Unit
|
||||
override var volume: Double
|
||||
get() = inner.volume
|
||||
set(_) = Unit
|
||||
|
||||
override fun canSetVolume(): Boolean = inner.canSetVolume
|
||||
override fun canSetSpeed(): Boolean = inner.canSetSpeed
|
||||
override fun resumePlayback() = inner.resumeVideo()
|
||||
override fun pausePlayback() = inner.pauseVideo()
|
||||
override fun stopPlayback() = inner.stopVideo()
|
||||
override fun seekTo(timeSeconds: Double) = inner.seekVideo(timeSeconds)
|
||||
override fun changeVolume(timeSeconds: Double) = inner.changeVolume(timeSeconds)
|
||||
override fun changeSpeed(speed: Double) = inner.changeSpeed(speed)
|
||||
override fun connect() = inner.start()
|
||||
override fun disconnect() = inner.stop()
|
||||
override fun getDeviceInfo(): CastingDeviceInfo = inner.getDeviceInfo()
|
||||
override fun getAddresses(): List<InetAddress> = inner.getAddresses()
|
||||
override fun loadVideo(
|
||||
streamType: String,
|
||||
contentType: String,
|
||||
contentId: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadVideo(streamType, contentType, contentId, resumePosition, duration, speed)
|
||||
|
||||
override fun loadContent(
|
||||
contentType: String,
|
||||
content: String,
|
||||
resumePosition: Double,
|
||||
duration: Double,
|
||||
speed: Double?,
|
||||
metadata: Metadata?
|
||||
) = inner.loadContent(contentType, content, resumePosition, duration, speed)
|
||||
|
||||
override fun ensureThreadStarted() = when (inner) {
|
||||
is FCastCastingDevice -> inner.ensureThreadStarted()
|
||||
is ChromecastCastingDevice -> inner.ensureThreadsStarted()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class ChromecastCastingDevice : CastingDevice {
|
||||
class ChromecastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: https://developers.google.com/cast/docs/media/messages
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.futo.platformplayer.casting
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
||||
@@ -25,7 +24,6 @@ import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -34,7 +32,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.math.BigInteger
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -72,7 +69,7 @@ enum class Opcode(val value: Byte) {
|
||||
}
|
||||
}
|
||||
|
||||
class FCastCastingDevice : CastingDevice {
|
||||
class FCastCastingDevice : CastingDeviceLegacy {
|
||||
//See for more info: TODO
|
||||
|
||||
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.fcast.sender_sdk.DeviceInfo as RsDeviceInfo
|
||||
import org.fcast.sender_sdk.ProtocolType
|
||||
import org.fcast.sender_sdk.CastContext
|
||||
import org.fcast.sender_sdk.NsdDeviceDiscoverer
|
||||
|
||||
class StateCastingExp : StateCasting() {
|
||||
private val _context = CastContext()
|
||||
var _deviceDiscoverer: NsdDeviceDiscoverer? = null
|
||||
|
||||
class DiscoveryEventHandler(
|
||||
private val onDeviceAdded: (RsDeviceInfo) -> Unit,
|
||||
private val onDeviceRemoved: (String) -> Unit,
|
||||
private val onDeviceUpdated: (RsDeviceInfo) -> Unit,
|
||||
) : org.fcast.sender_sdk.DeviceDiscovererEventHandler {
|
||||
override fun deviceAvailable(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceAdded(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceChanged(deviceInfo: RsDeviceInfo) {
|
||||
onDeviceUpdated(deviceInfo)
|
||||
}
|
||||
|
||||
override fun deviceRemoved(deviceName: String) {
|
||||
onDeviceRemoved(deviceName)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
org.fcast.sender_sdk.initLogger(org.fcast.sender_sdk.LogLevelFilter.DEBUG)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
try {
|
||||
val foundDeviceInfo = org.fcast.sender_sdk.deviceInfoFromUrl(url)!!
|
||||
val foundDevice = _context.createDeviceFromInfo(foundDeviceInfo)
|
||||
connectDevice(CastingDeviceExp(foundDevice))
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to handle URL: $e")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.")
|
||||
try {
|
||||
ad.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect from device: $e")
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return
|
||||
_started = true
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null
|
||||
|
||||
Logger.i(TAG, "CastingService starting...")
|
||||
|
||||
_castServer.start()
|
||||
enableDeveloper(true)
|
||||
|
||||
Logger.i(TAG, "CastingService started.")
|
||||
|
||||
_deviceDiscoverer = NsdDeviceDiscoverer(
|
||||
context,
|
||||
DiscoveryEventHandler(
|
||||
{ deviceInfo -> // Added
|
||||
Logger.i(TAG, "Device added: ${deviceInfo.name}")
|
||||
val device = _context.createDeviceFromInfo(deviceInfo)
|
||||
val deviceHandle = CastingDeviceExp(device)
|
||||
devices[deviceHandle.device.name()] = deviceHandle
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceAdded.emit(deviceHandle)
|
||||
}
|
||||
},
|
||||
{ deviceName -> // Removed
|
||||
invokeInMainScopeIfRequired {
|
||||
if (devices.containsKey(deviceName)) {
|
||||
val device = devices.remove(deviceName)
|
||||
if (device != null) {
|
||||
onDeviceRemoved.emit(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deviceInfo -> // Updated
|
||||
Logger.i(TAG, "Device updated: $deviceInfo")
|
||||
val handle = devices[deviceInfo.name]
|
||||
if (handle != null && handle is CastingDeviceExp) {
|
||||
handle.device.setPort(deviceInfo.port)
|
||||
handle.device.setAddresses(deviceInfo.addresses)
|
||||
invokeInMainScopeIfRequired {
|
||||
onDeviceChanged.emit(handle)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started) {
|
||||
return
|
||||
}
|
||||
|
||||
_started = false
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
_scopeIO.cancel()
|
||||
_scopeMain.cancel()
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice
|
||||
activeDevice = null
|
||||
try {
|
||||
d?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to disconnect device: $e")
|
||||
}
|
||||
|
||||
_castServer.stop()
|
||||
_castServer.removeAllHandlers()
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_deviceDiscoverer = null
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? = null
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDeviceExp? {
|
||||
try {
|
||||
val rsAddrs =
|
||||
deviceInfo.addresses.map { org.fcast.sender_sdk.tryIpAddrFromStr(it) }
|
||||
val rsDeviceInfo = RsDeviceInfo(
|
||||
name = deviceInfo.name,
|
||||
protocol = when (deviceInfo.type) {
|
||||
com.futo.platformplayer.casting.CastProtocolType.CHROMECAST -> ProtocolType.CHROMECAST
|
||||
com.futo.platformplayer.casting.CastProtocolType.FCAST -> ProtocolType.F_CAST
|
||||
else -> throw IllegalArgumentException()
|
||||
},
|
||||
addresses = rsAddrs,
|
||||
port = deviceInfo.port.toUShort(),
|
||||
)
|
||||
|
||||
return CastingDeviceExp(_context.createDeviceFromInfo(rsDeviceInfo))
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingExp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.futo.platformplayer.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.InetAddress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class StateCastingLegacy : StateCasting() {
|
||||
private var _nsdManager: NsdManager? = null
|
||||
|
||||
private val _discoveryListeners = mapOf(
|
||||
"_googlecast._tcp" to createDiscoveryListener(::addOrUpdateChromeCastDevice),
|
||||
"_airplay._tcp" to createDiscoveryListener(::addOrUpdateAirPlayDevice),
|
||||
"_fastcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice),
|
||||
"_fcast._tcp" to createDiscoveryListener(::addOrUpdateFastCastDevice)
|
||||
)
|
||||
|
||||
override fun handleUrl(url: String) {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "fcast") {
|
||||
throw Exception("Expected scheme to be FCast")
|
||||
}
|
||||
|
||||
val type = uri.host
|
||||
if (type != "r") {
|
||||
throw Exception("Expected type r")
|
||||
}
|
||||
|
||||
val connectionInfo = uri.pathSegments[0]
|
||||
val json =
|
||||
Base64.decode(connectionInfo, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||
.toString(Charsets.UTF_8)
|
||||
val networkConfig = Json.decodeFromString<FCastNetworkConfig>(json)
|
||||
val tcpService = networkConfig.services.first { v -> v.type == 0 }
|
||||
|
||||
val foundInfo = addRememberedDevice(
|
||||
CastingDeviceInfo(
|
||||
name = networkConfig.name,
|
||||
type = CastProtocolType.FCAST,
|
||||
addresses = networkConfig.addresses.toTypedArray(),
|
||||
port = tcpService.port
|
||||
)
|
||||
)
|
||||
|
||||
if (foundInfo != null) {
|
||||
connectDevice(deviceFromInfo(foundInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
val ad = activeDevice ?: return;
|
||||
_resumeCastingDevice = ad.getDeviceInfo()
|
||||
Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'")
|
||||
Logger.i(TAG, "Stopping active device because of onStop.");
|
||||
ad.disconnect();
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun start(context: Context) {
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
Log.i(TAG, "_resumeCastingDevice set null start")
|
||||
_resumeCastingDevice = null;
|
||||
|
||||
Logger.i(TAG, "CastingService starting...");
|
||||
|
||||
_castServer.start();
|
||||
enableDeveloper(true);
|
||||
|
||||
Logger.i(TAG, "CastingService started.");
|
||||
|
||||
_nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
startDiscovering()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
discoverServices(it.key, NsdManager.PROTOCOL_DNS_SD, it.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopDiscovering() {
|
||||
_nsdManager?.apply {
|
||||
_discoveryListeners.forEach {
|
||||
try {
|
||||
stopServiceDiscovery(it.value)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
if (!_started)
|
||||
return;
|
||||
|
||||
_started = false;
|
||||
|
||||
Logger.i(TAG, "CastingService stopping.")
|
||||
|
||||
stopDiscovering()
|
||||
_scopeIO.cancel();
|
||||
_scopeMain.cancel();
|
||||
|
||||
Logger.i(TAG, "Stopping active device because StateCasting is being stopped.")
|
||||
val d = activeDevice;
|
||||
activeDevice = null;
|
||||
d?.disconnect();
|
||||
|
||||
_castServer.stop();
|
||||
_castServer.removeAllHandlers();
|
||||
|
||||
Logger.i(TAG, "CastingService stopped.")
|
||||
|
||||
_nsdManager = null
|
||||
}
|
||||
|
||||
private fun createDiscoveryListener(addOrUpdate: (String, Array<InetAddress>, Int) -> Unit): NsdManager.DiscoveryListener {
|
||||
return object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(regType: String) {
|
||||
Log.d(TAG, "Service discovery started for $regType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String) {
|
||||
Log.i(TAG, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceLost(service: NsdServiceInfo) {
|
||||
Log.e(TAG, "service lost: $service")
|
||||
// TODO: Handle service lost, e.g., remove device
|
||||
}
|
||||
|
||||
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
|
||||
Log.e(TAG, "Stop discovery failed for $serviceType: Error code:$errorCode")
|
||||
try {
|
||||
_nsdManager?.stopServiceDiscovery(this)
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to stop service discovery", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceFound(service: NsdServiceInfo) {
|
||||
Log.v(TAG, "Service discovery success for ${service.serviceType}: $service")
|
||||
val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
service.hostAddresses.toTypedArray()
|
||||
} else {
|
||||
arrayOf(service.host)
|
||||
}
|
||||
addOrUpdate(service.serviceName, addresses, service.port)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
_nsdManager?.registerServiceInfoCallback(
|
||||
service,
|
||||
{ it.run() },
|
||||
object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "onServiceUpdated: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
serviceInfo.hostAddresses.toTypedArray(),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
Log.v(TAG, "onServiceLost: $service")
|
||||
// TODO: Handle service lost
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
Log.v(TAG, "onServiceInfoCallbackRegistrationFailed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
Log.v(TAG, "onServiceInfoCallbackUnregistered")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
_nsdManager?.resolveService(service, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.v(TAG, "Resolve failed: $errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
|
||||
Log.v(TAG, "Resolve Succeeded: $serviceInfo")
|
||||
addOrUpdate(
|
||||
serviceInfo.serviceName,
|
||||
arrayOf(serviceInfo.host),
|
||||
serviceInfo.port
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s: Event1<Long>,
|
||||
setTime: (Long) -> Unit
|
||||
): Job? {
|
||||
val d = activeDevice;
|
||||
if (d is CastingDeviceLegacyWrapper && (d.inner is AirPlayCastingDevice || d.inner is ChromecastCastingDevice)) {
|
||||
return _scopeMain.launch {
|
||||
while (true) {
|
||||
val device = instance.activeDevice
|
||||
if (device == null || !device.isPlaying) {
|
||||
break
|
||||
}
|
||||
|
||||
delay(1000)
|
||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
||||
setTime(time_ms)
|
||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun deviceFromInfo(deviceInfo: CastingDeviceInfo): CastingDevice {
|
||||
return CastingDeviceLegacyWrapper(
|
||||
when (deviceInfo.type) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
ChromecastCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
AirPlayCastingDevice(deviceInfo);
|
||||
}
|
||||
|
||||
CastProtocolType.FCAST -> {
|
||||
FCastCastingDevice(deviceInfo);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun addOrUpdateChromeCastDevice(
|
||||
name: String,
|
||||
addresses: Array<InetAddress>,
|
||||
port: Int
|
||||
) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
ChromecastCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is ChromecastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(d.inner.addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.addresses = addresses;
|
||||
d.inner.port = port;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateAirPlayDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = {
|
||||
CastingDeviceLegacyWrapper(
|
||||
AirPlayCastingDevice(
|
||||
name,
|
||||
addresses,
|
||||
port
|
||||
)
|
||||
)
|
||||
},
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is AirPlayCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private fun addOrUpdateFastCastDevice(name: String, addresses: Array<InetAddress>, port: Int) {
|
||||
return addOrUpdateCastDevice(
|
||||
name,
|
||||
deviceFactory = { CastingDeviceLegacyWrapper(FCastCastingDevice(name, addresses, port)) },
|
||||
deviceUpdater = { d ->
|
||||
if (d.isReady || d !is CastingDeviceLegacyWrapper || d.inner !is FCastCastingDevice) {
|
||||
return@addOrUpdateCastDevice false;
|
||||
}
|
||||
|
||||
val changed =
|
||||
addresses.contentEquals(addresses) || d.name != name || d.inner.port != port;
|
||||
if (changed) {
|
||||
d.inner.name = name;
|
||||
d.inner.port = port;
|
||||
d.inner.addresses = addresses;
|
||||
}
|
||||
|
||||
return@addOrUpdateCastDevice changed;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private inline fun addOrUpdateCastDevice(
|
||||
name: String,
|
||||
deviceFactory: () -> CastingDevice,
|
||||
deviceUpdater: (device: CastingDevice) -> Boolean
|
||||
) {
|
||||
var invokeEvents: (() -> Unit)? = null;
|
||||
|
||||
synchronized(devices) {
|
||||
val device = devices[name];
|
||||
if (device != null) {
|
||||
val changed = deviceUpdater(device);
|
||||
if (changed) {
|
||||
invokeEvents = {
|
||||
onDeviceChanged.emit(device);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val newDevice = deviceFactory();
|
||||
this.devices[name] = newDevice
|
||||
|
||||
invokeEvents = {
|
||||
onDeviceAdded.emit(newDevice);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
invokeEvents?.let { _scopeMain.launch { it(); }; };
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class FCastNetworkConfig(
|
||||
val name: String,
|
||||
val addresses: List<String>,
|
||||
val services: List<FCastService>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class FCastService(
|
||||
val port: Int,
|
||||
val type: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = "StateCastingLegacy"
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.toInetAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
|
||||
class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
@@ -38,7 +40,13 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
_buttonConfirm = findViewById(R.id.button_confirm);
|
||||
_buttonTutorial = findViewById(R.id.button_tutorial)
|
||||
|
||||
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
|
||||
val deviceTypeArray = if (Settings.instance.casting.experimentalCasting) {
|
||||
R.array.exp_casting_device_type_array
|
||||
} else {
|
||||
R.array.casting_device_type_array
|
||||
}
|
||||
|
||||
ArrayAdapter.createFromResource(context, deviceTypeArray, R.layout.spinner_item_simple).also { adapter ->
|
||||
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
_spinnerType.adapter = adapter;
|
||||
};
|
||||
@@ -101,7 +109,11 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_textError.visibility = View.GONE;
|
||||
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
|
||||
try {
|
||||
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to add remembered device: $e")
|
||||
}
|
||||
performDismiss();
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
@@ -18,7 +17,6 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
@@ -108,15 +106,16 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
synchronized(StateCasting.instance.devices) {
|
||||
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
|
||||
}
|
||||
|
||||
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
|
||||
|
||||
updateUnifiedList()
|
||||
|
||||
StateCasting.instance.onDeviceAdded.subscribe(this) { d ->
|
||||
val name = d.name
|
||||
if (name != null)
|
||||
if (name != null) {
|
||||
_devices.add(name)
|
||||
updateUnifiedList()
|
||||
updateUnifiedList()
|
||||
}
|
||||
}
|
||||
|
||||
StateCasting.instance.onDeviceChanged.subscribe(this) { d ->
|
||||
|
||||
@@ -12,12 +12,11 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -69,18 +68,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonPlay = findViewById(R.id.button_play);
|
||||
_buttonPlay.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.resumeVideo()
|
||||
StateCasting.instance.resumeVideo()
|
||||
}
|
||||
|
||||
_buttonPause = findViewById(R.id.button_pause);
|
||||
_buttonPause.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.pauseVideo()
|
||||
StateCasting.instance.pauseVideo()
|
||||
}
|
||||
|
||||
_buttonStop = findViewById(R.id.button_stop);
|
||||
_buttonStop.setOnClickListener {
|
||||
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
|
||||
StateCasting.instance.activeDevice?.stopVideo()
|
||||
StateCasting.instance.stopVideo()
|
||||
}
|
||||
|
||||
_buttonNext = findViewById(R.id.button_next);
|
||||
@@ -90,7 +89,11 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
|
||||
_buttonClose.setOnClickListener { dismiss(); };
|
||||
_buttonDisconnect.setOnClickListener {
|
||||
StateCasting.instance.activeDevice?.stopCasting();
|
||||
try {
|
||||
StateCasting.instance.activeDevice?.disconnect()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Active device failed to disconnect: $e")
|
||||
}
|
||||
dismiss();
|
||||
};
|
||||
|
||||
@@ -99,12 +102,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
try {
|
||||
activeDevice.seekVideo(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
StateCasting.instance.videoSeekTo(value.toDouble())
|
||||
});
|
||||
|
||||
//TODO: Check if volume slider is properly hidden in all cases
|
||||
@@ -113,14 +111,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
return@OnChangeListener
|
||||
}
|
||||
|
||||
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
|
||||
if (activeDevice.canSetVolume) {
|
||||
try {
|
||||
activeDevice.changeVolume(value.toDouble());
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change volume.", e);
|
||||
}
|
||||
}
|
||||
StateCasting.instance.changeVolume(value.toDouble())
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
@@ -172,15 +163,25 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
private fun updateDevice() {
|
||||
val d = StateCasting.instance.activeDevice ?: return;
|
||||
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FastCast";
|
||||
when (d.protocolType) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
}
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
_textName.text = d.name;
|
||||
@@ -192,7 +193,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
_sliderPosition.value = d.time.toFloat().coerceAtLeast(0.0f).coerceAtMost(dur)
|
||||
_sliderPosition.valueTo = dur
|
||||
|
||||
if (d.canSetVolume) {
|
||||
if (d.canSetVolume()) {
|
||||
_layoutVolumeAdjustable.visibility = View.VISIBLE;
|
||||
_layoutVolumeFixed.visibility = View.GONE;
|
||||
} else {
|
||||
@@ -214,8 +215,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
|
||||
CastConnectionState.CONNECTED -> {
|
||||
enableControls(interactiveControls)
|
||||
}
|
||||
CastConnectionState.CONNECTING,
|
||||
CastConnectionState.DISCONNECTED -> {
|
||||
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
|
||||
disableControls(interactiveControls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,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 +37,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
|
||||
@@ -242,10 +244,12 @@ class V8Plugin {
|
||||
}
|
||||
fun <T> busy(handle: ()->T): T {
|
||||
_busyLock.lock();
|
||||
//Logger.i(TAG, "Busy Enter [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
try {
|
||||
return handle();
|
||||
}
|
||||
finally {
|
||||
//Logger.i(TAG, "Busy Leave [" + _busyLock.holdCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString())
|
||||
_busyLock.unlock();
|
||||
}
|
||||
/*
|
||||
@@ -511,18 +515,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,11 @@ class PackageBridge : V8Package {
|
||||
|
||||
val stackTrace = Thread.currentThread().stackTrace;
|
||||
val callerMethod = stackTrace.findLast {
|
||||
it.className == JSClient::class.java.name
|
||||
it.className == JSClient::class.java.name &&
|
||||
it.methodName != "isBusy" &&
|
||||
it.methodName != "busy" &&
|
||||
it.methodName != "getCopy" &&
|
||||
it.methodName != "isBusyWith"
|
||||
}?.methodName ?: "";
|
||||
val session = StateApp.instance.sessionId;
|
||||
val pluginId = _plugin.config.id;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -279,6 +279,14 @@ class HomeFragment : MainFragment() {
|
||||
else {
|
||||
view.setToggle(!active);
|
||||
}
|
||||
}, { view, views, enabled ->
|
||||
val toDisable = views.filter { it != view && it.tag == "plugins" };
|
||||
if(!view.isActive)
|
||||
view.handleClick();
|
||||
for(tag in toDisable) {
|
||||
if(tag.isActive)
|
||||
tag.handleClick();
|
||||
}
|
||||
}).withTag("plugins")
|
||||
})
|
||||
else listOf())
|
||||
|
||||
+41
-1
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.views.buttons.BigButton
|
||||
@@ -152,11 +153,50 @@ class SourceDetailFragment : MainFragment() {
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
if(!source.capabilities.hasGetUserHistory) {
|
||||
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
|
||||
val field = _settingsAppForm.findField("sync");
|
||||
if(field is View)
|
||||
field.isVisible = false;
|
||||
}
|
||||
else {
|
||||
val field = _settingsAppForm.findField("syncHistory");
|
||||
field?.onChanged?.subscribe { field, new, old ->
|
||||
if(old != new && new == true && StatePlatform.instance.isClientEnabled(config.id)) {
|
||||
UIDialogs.showDialog(context, R.drawable.ic_sources, "Would you like to sync now?",
|
||||
"This will attempt to update your history from the platform, when this setting is enabled, it is done during startup.", null, 0,
|
||||
UIDialogs.Action("No", {
|
||||
|
||||
}),
|
||||
UIDialogs.Action("Yes", {
|
||||
UIDialogs.showDialogProgress(context, {
|
||||
it.setText("Importing history..");
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = StatePlatform.instance.getClient(config.id);
|
||||
if (client != null && client is JSClient) {
|
||||
val count = StateHistory.instance.syncRemoteHistory(client);
|
||||
withContext(Dispatchers.Main) {
|
||||
it.hide();
|
||||
if(count > 0)
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_pair_success, "Imported ${count} history items");
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_help, "Imported no history items");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.appToast("Sync History failed due to:\n" + ex.message);
|
||||
it.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
}
|
||||
_settingsAppForm.onChanged.clear();
|
||||
_settingsAppForm.onChanged.subscribe { field, value ->
|
||||
_settingsAppChanged = true;
|
||||
|
||||
+3
-3
@@ -437,7 +437,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
val viewDetail = _viewDetail;
|
||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
|
||||
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.isAudioOnlyUserAction}");
|
||||
|
||||
if (viewDetail === null) {
|
||||
return
|
||||
@@ -446,7 +446,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
if (viewDetail.shouldEnterPictureInPicture) {
|
||||
_leavingPiP = false
|
||||
}
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
|
||||
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.isAudioOnlyUserAction) {
|
||||
val params = _viewDetail?.getPictureInPictureParams();
|
||||
if(params != null) {
|
||||
Logger.i(TAG, "enterPictureInPictureMode")
|
||||
@@ -526,7 +526,7 @@ class VideoDetailFragment() : MainFragment() {
|
||||
|
||||
private fun stopIfRequired() {
|
||||
var shouldStop = true;
|
||||
if (_viewDetail?.allowBackground == true) {
|
||||
if (_viewDetail?.isAudioOnlyUserAction == true) {
|
||||
shouldStop = false;
|
||||
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
|
||||
shouldStop = false;
|
||||
|
||||
+50
-29
@@ -10,7 +10,6 @@ import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
@@ -51,7 +50,6 @@ import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SyncShowPairingCodeActivity.Companion.activity
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
@@ -82,7 +80,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
@@ -326,7 +323,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val onEnterPictureInPicture = Event0();
|
||||
val onVideoChanged = Event2<Int, Int>()
|
||||
|
||||
var allowBackground: Boolean = false
|
||||
var isAudioOnlyUserAction: Boolean = false
|
||||
private set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
@@ -338,7 +335,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
get() = !preventPictureInPicture &&
|
||||
!StateCasting.instance.isCasting &&
|
||||
Settings.instance.playback.isBackgroundPictureInPicture() &&
|
||||
!allowBackground &&
|
||||
!isAudioOnlyUserAction &&
|
||||
isPlaying
|
||||
|
||||
val onShouldEnterPictureInPictureChanged = Event0();
|
||||
@@ -579,9 +576,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
|
||||
val ad = StateCasting.instance.activeDevice
|
||||
if (ad != null) {
|
||||
ad.seekVideo(chapter.timeEnd)
|
||||
if (StateCasting.instance.activeDevice != null) {
|
||||
StateCasting.instance.videoSeekTo(chapter.timeEnd)
|
||||
} else {
|
||||
_player.seekTo((chapter.timeEnd * 1000).toLong());
|
||||
}
|
||||
@@ -764,7 +760,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
isAudioOnlyUserAction = true;
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
try {
|
||||
if (it is MainActivity) {
|
||||
@@ -889,7 +885,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (ad != null) {
|
||||
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
ad.seekVideo(currentChapter.timeEnd);
|
||||
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
|
||||
}
|
||||
} else {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
@@ -1008,15 +1004,26 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
} else if(video is JSVideoDetails && (video as JSVideoDetails).hasVODEvents())
|
||||
RoundButton(context, R.drawable.ic_chat, context.getString(R.string.vod_chat), TAG_VODCHAT) {
|
||||
video?.let {
|
||||
try {
|
||||
loadVODChat(it);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to reopen vod chat", ex);
|
||||
}
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
} else null,
|
||||
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (allowBackground) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
||||
if (!allowBackground) {
|
||||
if (!isLimitedVersion) RoundButton(context, R.drawable.ic_screen_share, if (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
|
||||
if (!isAudioOnlyUserAction) {
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
isAudioOnlyUserAction = true;
|
||||
it.text.text = resources.getString(R.string.background_revert);
|
||||
} else {
|
||||
_player.switchToVideoMode();
|
||||
allowBackground = false;
|
||||
isAudioOnlyUserAction = false;
|
||||
it.text.text = resources.getString(R.string.background);
|
||||
}
|
||||
_slideUpOverlay?.hide();
|
||||
@@ -1132,19 +1139,23 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
|
||||
//Lifecycle
|
||||
var isLoginStop = false; //TODO: This is a bit jank, but easiest solution for now without reworking flow. (Alternatively, fix MainActivity getting stopped/disposing video)
|
||||
fun onResume() {
|
||||
Logger.v(TAG, "onResume");
|
||||
_onPauseCalled = false;
|
||||
|
||||
val wasLoginCall = isLoginStop;
|
||||
isLoginStop = false;
|
||||
|
||||
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
||||
Logger.i(TAG, "_didStop: $_didStop");
|
||||
|
||||
//Recover cancelled loads
|
||||
if(video == null) {
|
||||
val t = (lastPositionMilliseconds / 1000.0f).roundToLong();
|
||||
if(_searchVideo != null)
|
||||
if(_searchVideo != null && !wasLoginCall)
|
||||
setVideoOverview(_searchVideo!!, true, t);
|
||||
else if(_url != null)
|
||||
else if(_url != null && !wasLoginCall)
|
||||
setVideo(_url!!, t, _playWhenReady);
|
||||
}
|
||||
else if(_didStop) {
|
||||
@@ -1156,11 +1167,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
if(_player.isAudioMode) {
|
||||
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
|
||||
if(!allowBackground) {
|
||||
if(!isAudioOnlyUserAction) {
|
||||
_player.switchToVideoMode();
|
||||
allowBackground = false;
|
||||
isAudioOnlyUserAction = false;
|
||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
|
||||
}
|
||||
else {
|
||||
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
|
||||
}
|
||||
}
|
||||
if(!_player.isFitMode && !_player.isFullScreen && !fragment.isInPictureInPicture)
|
||||
_player.fitHeight();
|
||||
@@ -1176,7 +1190,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(StateCasting.instance.isCasting)
|
||||
return;
|
||||
|
||||
if(allowBackground)
|
||||
if(isAudioOnlyUserAction)
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
else {
|
||||
when (Settings.instance.playback.backgroundPlay) {
|
||||
@@ -1184,7 +1198,6 @@ class VideoDetailView : ConstraintLayout {
|
||||
1 -> {
|
||||
if(!(video?.isLive ?: false)) {
|
||||
_player.switchToAudioMode(video);
|
||||
allowBackground = true;
|
||||
}
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
}
|
||||
@@ -1972,10 +1985,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
if (isLimitedVersion && _player.isAudioMode) {
|
||||
_player.switchToVideoMode()
|
||||
allowBackground = false;
|
||||
isAudioOnlyUserAction = false;
|
||||
} else {
|
||||
val thumbnail = video.thumbnails.getHQThumbnail();
|
||||
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank())
|
||||
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
@@ -2354,11 +2367,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
?.distinct()
|
||||
?.toList() ?: listOf() else audioSources?.toList() ?: listOf();
|
||||
|
||||
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true
|
||||
val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed() == true
|
||||
val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()
|
||||
val qualityPlaybackSpeedTitle = if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate) + " (${String.format("%.2f", currentPlaybackRate)})"); } else null;
|
||||
_overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString(
|
||||
R.string.quality), null, true,
|
||||
R.string.quality), null, true,
|
||||
qualityPlaybackSpeedTitle,
|
||||
if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply {
|
||||
val playbackSpeeds = Settings.instance.playback.getPlaybackSpeeds();
|
||||
@@ -2379,7 +2392,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
val newPlaybackSpeed = playbackSpeedString.toDouble();
|
||||
if (_isCasting) {
|
||||
val ad = StateCasting.instance.activeDevice ?: return@subscribe
|
||||
if (!ad.canSetSpeed) {
|
||||
if (!ad.canSetSpeed()) {
|
||||
return@subscribe
|
||||
}
|
||||
|
||||
@@ -2503,6 +2516,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!StateCasting.instance.resumeVideo()) {
|
||||
_player.play();
|
||||
}
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
|
||||
//TODO: This was needed because handleLowerVolume was done.
|
||||
//_player.setVolume(1.0f);
|
||||
@@ -2519,6 +2533,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!StateCasting.instance.pauseVideo()) {
|
||||
_player.pause();
|
||||
}
|
||||
onShouldEnterPictureInPictureChanged.emit()
|
||||
}
|
||||
private fun handleSeek(ms: Long) {
|
||||
Logger.i(TAG, "handleSeek(ms=$ms)")
|
||||
@@ -3023,9 +3038,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));
|
||||
|
||||
@@ -3265,8 +3280,13 @@ class VideoDetailView : ConstraintLayout {
|
||||
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
|
||||
val didLogin = if(id == null)
|
||||
false
|
||||
else StatePlugins.instance.loginPlugin(context, id) {
|
||||
fetchVideo();
|
||||
else {
|
||||
isLoginStop = true;
|
||||
StatePlugins.instance.loginPlugin(context, id) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
fetchVideo();
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!didLogin)
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
|
||||
@@ -3444,6 +3464,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
const val TAG_SHARE = "share";
|
||||
const val TAG_OVERLAY = "overlay";
|
||||
const val TAG_LIVECHAT = "livechat";
|
||||
const val TAG_VODCHAT = "vodchat";
|
||||
const val TAG_CHAPTERS = "chapters";
|
||||
const val TAG_OPEN = "open";
|
||||
const val TAG_SEND_TO_DEVICE = "send_to_device";
|
||||
|
||||
@@ -3,16 +3,9 @@ package com.futo.platformplayer.models
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class CastingDeviceInfo {
|
||||
var name: String;
|
||||
var type: CastProtocolType;
|
||||
var addresses: Array<String>;
|
||||
var port: Int;
|
||||
|
||||
constructor(name: String, type: CastProtocolType, addresses: Array<String>, port: Int) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.addresses = addresses;
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
class CastingDeviceInfo(
|
||||
var name: String,
|
||||
var type: CastProtocolType,
|
||||
var addresses: Array<String>,
|
||||
var port: Int
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.futo.platformplayer.polycentric
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import org.json.JSONObject
|
||||
|
||||
class ModerationsManager private constructor(context: Context) {
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("polycentric_moderation", Context.MODE_PRIVATE)
|
||||
|
||||
private val _moderationLevels = MutableLiveData<Map<String, Int>>()
|
||||
val moderationLevels: LiveData<Map<String, Int>> = _moderationLevels
|
||||
|
||||
init {
|
||||
loadModerationLevels()
|
||||
}
|
||||
|
||||
private fun loadModerationLevels() {
|
||||
val levels = mutableMapOf<String, Int>()
|
||||
levels["hate"] = prefs.getInt("offensive_level", 2)
|
||||
levels["sexual"] = prefs.getInt("explicit_level", 1)
|
||||
levels["violence"] = prefs.getInt("violence_level", 1)
|
||||
_moderationLevels.value = levels
|
||||
}
|
||||
|
||||
fun setModerationLevel(category: String, level: Int) {
|
||||
when (category) {
|
||||
"hate" -> prefs.edit().putInt("offensive_level", level).apply()
|
||||
"sexual" -> prefs.edit().putInt("explicit_level", level).apply()
|
||||
"violence" -> prefs.edit().putInt("violence_level", level).apply()
|
||||
}
|
||||
|
||||
val currentMap = _moderationLevels.value?.toMutableMap() ?: mutableMapOf()
|
||||
currentMap[category] = level
|
||||
_moderationLevels.value = currentMap
|
||||
}
|
||||
|
||||
fun getModerationLevelsJson(): String {
|
||||
val json = JSONObject()
|
||||
moderationLevels.value?.forEach { (key, value) ->
|
||||
json.put(key, value)
|
||||
}
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
fun shouldFilter(category: String, contentLevel: Int): Boolean {
|
||||
val userLevel = when (category) {
|
||||
"hate" -> prefs.getInt("offensive_level", 2)
|
||||
"sexual" -> prefs.getInt("explicit_level", 1)
|
||||
"violence" -> prefs.getInt("violence_level", 1)
|
||||
else -> 3
|
||||
}
|
||||
|
||||
return contentLevel > userLevel
|
||||
}
|
||||
|
||||
fun getCurrentModerationLevels(): Map<String, Int>? {
|
||||
return moderationLevels.value
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: ModerationsManager? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (instance == null) {
|
||||
synchronized(this) {
|
||||
if (instance == null) {
|
||||
instance = ModerationsManager(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(): ModerationsManager {
|
||||
return instance ?: throw IllegalStateException("ModerationsManager not initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,9 @@ class DownloadService : Service() {
|
||||
return START_NOT_STICKY;
|
||||
|
||||
if(!FragmentedStorage.isInitialized) {
|
||||
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
|
||||
stopSelf()
|
||||
closeDownloadSession();
|
||||
return START_NOT_STICKY;
|
||||
Logger.i(TAG, "Attempted to start DownloadService without initialized files")
|
||||
closeDownloadSession()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
_started = true;
|
||||
}
|
||||
@@ -107,12 +106,19 @@ class DownloadService : Service() {
|
||||
return START_STICKY;
|
||||
}
|
||||
fun setupNotificationRequirements() {
|
||||
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
||||
_notificationChannel = NotificationChannel(DOWNLOAD_NOTIF_CHANNEL_ID, DOWNLOAD_NOTIF_CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||
this.enableVibration(false);
|
||||
this.setSound(null, null);
|
||||
};
|
||||
_notificationManager!!.createNotificationChannel(_notificationChannel!!);
|
||||
_notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (_notificationChannel == null) {
|
||||
_notificationChannel = NotificationChannel(
|
||||
DOWNLOAD_NOTIF_CHANNEL_ID,
|
||||
DOWNLOAD_NOTIF_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
setShowBadge(false)
|
||||
}
|
||||
}
|
||||
_notificationManager?.createNotificationChannel(_notificationChannel!!)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -293,21 +299,28 @@ class DownloadService : Service() {
|
||||
val notif = builder.build();
|
||||
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
if (_isForeground) {
|
||||
_notificationManager?.notify(DOWNLOAD_NOTIF_ID, notif)
|
||||
} else {
|
||||
startForeground(DOWNLOAD_NOTIF_ID, notif);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
else
|
||||
startForeground(DOWNLOAD_NOTIF_ID, notif)
|
||||
_isForeground = true
|
||||
}
|
||||
}
|
||||
|
||||
fun closeDownloadSession() {
|
||||
Logger.i(TAG, "closeDownloadSession");
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID);
|
||||
stopService();
|
||||
_started = false;
|
||||
super.stopSelf();
|
||||
Logger.i(TAG, "closeDownloadSession")
|
||||
if (_isForeground) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
_isForeground = false
|
||||
}
|
||||
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID)
|
||||
_started = false
|
||||
super.stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.i(TAG, "onDestroy");
|
||||
_instance = null;
|
||||
|
||||
@@ -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.*
|
||||
@@ -135,8 +137,12 @@ class StateApp {
|
||||
return _scope;
|
||||
}
|
||||
val scope: CoroutineScope get() {
|
||||
val thisScope = scopeOrNull
|
||||
?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
|
||||
val thisScope = scopeOrNull;
|
||||
if(thisScope == null) {
|
||||
//throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
|
||||
Logger.w(TAG, "Attempted to use a global lifetime scope while MainActivity is no longer available, USING GLOBAL SCOPE");
|
||||
return GlobalScope;
|
||||
}
|
||||
return thisScope;
|
||||
}
|
||||
val scopeGetter: ()->CoroutineScope get() {
|
||||
@@ -381,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);
|
||||
|
||||
@@ -194,17 +194,18 @@ class StateHistory {
|
||||
_remoteHistoryDatesStore.save();
|
||||
}
|
||||
|
||||
fun syncRemoteHistory(plugin: JSClient) {
|
||||
fun syncRemoteHistory(plugin: JSClient): Int {
|
||||
if (plugin.capabilities.hasGetUserHistory &&
|
||||
plugin.isLoggedIn) {
|
||||
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
|
||||
|
||||
val hist = StatePlatform.instance.getUserHistory(plugin.id);
|
||||
|
||||
syncRemoteHistory(plugin.id, hist, 100, 3);
|
||||
return syncRemoteHistory(plugin.id, hist, 100, 3);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) {
|
||||
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): Int {
|
||||
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
|
||||
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
|
||||
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
|
||||
@@ -272,12 +273,14 @@ class StateHistory {
|
||||
}
|
||||
catch(ex: Throwable){}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
val plugin = if(pluginId != StateDeveloper.DEV_ID) StatePlugins.instance.getPlugin(pluginId) else null;
|
||||
Logger.e(TAG, "Sync Remote History failed for [${plugin?.config?.name}] due to: " + ex.message)
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -177,16 +177,11 @@ class StatePlatform {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
var toDisables = mutableListOf<IPlatformClient>();
|
||||
var enabled: Array<String>;
|
||||
synchronized(_clientsLock) {
|
||||
for(e in _enabledClients) {
|
||||
try {
|
||||
e.disable();
|
||||
onSourceDisabled.emit(e);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
UIDialogs.appToast(ToastView.Toast("If this happens often, please inform the developers on Github", false, null, "Plugin [${e.name}] failed to disable"));
|
||||
}
|
||||
toDisables.add(e);
|
||||
}
|
||||
|
||||
_enabledClients.clear();
|
||||
@@ -236,6 +231,18 @@ class StatePlatform {
|
||||
}
|
||||
}
|
||||
selectClients(*enabled);
|
||||
|
||||
for(toDisable in toDisables) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
toDisable.disable();
|
||||
onSourceDisabled.emit(toDisable);
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER UpdateAvailableClients", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -348,11 +355,11 @@ class StatePlatform {
|
||||
StateApp.instance.handleCaptchaException(c, ex);
|
||||
}
|
||||
|
||||
var toDisable: IPlatformClient? = null;
|
||||
synchronized(_clientsLock) {
|
||||
if (_enabledClients.contains(client)) {
|
||||
_enabledClients.remove(client);
|
||||
client.disable();
|
||||
onSourceDisabled.emit(client);
|
||||
toDisable = client;
|
||||
newClient.initialize();
|
||||
_enabledClients.add(newClient);
|
||||
}
|
||||
@@ -360,6 +367,18 @@ class StatePlatform {
|
||||
_availableClients.removeIf { it.id == id };
|
||||
_availableClients.add(newClient);
|
||||
}
|
||||
if(toDisable != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
toDisable?.disable();
|
||||
onSourceDisabled.emit(client);
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "FAILED TO DISABLE CLIENT [${toDisable?.name}] AFTER RELOAD", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterReload?.invoke();
|
||||
return@withContext newClient;
|
||||
};
|
||||
|
||||
@@ -179,8 +179,9 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||
StatePlatform.instance.reloadClient(context, id);
|
||||
afterLogin.invoke();
|
||||
StatePlatform.instance.reloadClient(context, id) {
|
||||
afterLogin.invoke();
|
||||
}
|
||||
}
|
||||
};
|
||||
return true;
|
||||
@@ -401,18 +402,25 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setText("Saving plugin...");
|
||||
it.setProgress(0.75);
|
||||
}
|
||||
val iconResp = client.get(absIconUrl);
|
||||
if(iconResp.isOk)
|
||||
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
||||
return@let null;
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setText("Saving plugin...");
|
||||
it.setProgress(0.75);
|
||||
}
|
||||
|
||||
val installEx = StatePlugins.instance.createPlugin(config, script, icon, reinstall);
|
||||
if(installEx != null)
|
||||
throw installEx;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
it.setText("Reloading available plugins...");
|
||||
it.setProgress(0.9);
|
||||
}
|
||||
StatePlatform.instance.updateAvailableClients(context);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -475,6 +483,7 @@ class StatePlugins {
|
||||
delay(500);
|
||||
|
||||
val client = ManagedHttpClient();
|
||||
client.setTimeout(10000);
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Validating script", 0.25);
|
||||
@@ -489,14 +498,14 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
val icon = config.absoluteIconUrl?.let { absIconUrl ->
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Saving plugin", 0.75);
|
||||
}
|
||||
val iconResp = client.get(absIconUrl);
|
||||
if (iconResp.isOk)
|
||||
return@let iconResp.body?.byteStream()?.use { it.readBytes() };
|
||||
return@let null;
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
onProgress.invoke("Saving plugin", 0.75);
|
||||
}
|
||||
val installEx = StatePlugins.instance.createPlugin(config, script, icon, true);
|
||||
if (installEx != null)
|
||||
throw installEx;
|
||||
@@ -520,9 +529,7 @@ class StatePlugins {
|
||||
if(id == StateDeveloper.DEV_ID)
|
||||
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
|
||||
|
||||
synchronized(_plugins) {
|
||||
return _plugins.findItem { it.config.id == id };
|
||||
}
|
||||
return _plugins.findItem { it.config.id == id };
|
||||
}
|
||||
fun getPlugins(): List<SourcePluginDescriptor> {
|
||||
return _plugins.getItems();
|
||||
@@ -531,12 +538,10 @@ class StatePlugins {
|
||||
|
||||
fun deletePlugin(id: String) {
|
||||
synchronized(_pluginScripts) {
|
||||
synchronized(_plugins) {
|
||||
_pluginScripts.deleteFile(id);
|
||||
val plugins = _plugins.findItems { it.config.id == id };
|
||||
for(plugin in plugins)
|
||||
_plugins.delete(plugin);
|
||||
}
|
||||
_pluginScripts.deleteFile(id);
|
||||
val plugins = _plugins.findItems { it.config.id == id };
|
||||
for(plugin in plugins)
|
||||
_plugins.delete(plugin);
|
||||
}
|
||||
}
|
||||
fun createPlugin(config: SourcePluginConfig, script: String, icon: ByteArray? = null, reinstall: Boolean = false, flags: List<String> = listOf()) : Throwable? {
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ensureServerAndBackfill
|
||||
import com.futo.polycentric.core.ClaimType
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Opinion
|
||||
@@ -46,8 +47,10 @@ import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import userpackage.Protocol
|
||||
import userpackage.Protocol.Reference
|
||||
@@ -67,6 +70,8 @@ class StatePolycentric {
|
||||
|
||||
private val _commentPool = ForkJoinPool(2);
|
||||
private val _commentPoolDispatcher = _commentPool.asCoroutineDispatcher();
|
||||
private val _backgroundJob = SupervisorJob()
|
||||
private val _backgroundScope = CoroutineScope(_backgroundJob + Dispatchers.IO)
|
||||
|
||||
fun load(context: Context) {
|
||||
if (!enabled) {
|
||||
@@ -173,6 +178,15 @@ class StatePolycentric {
|
||||
}
|
||||
|
||||
_likeDislikeMap = newMap
|
||||
|
||||
// Ensure current server is registered & synced
|
||||
_backgroundScope.launch {
|
||||
try {
|
||||
processHandle.ensureServerAndBackfill()
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to ensure server and backfill: "+e.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_activeProcessHandle.setAndSave("");
|
||||
_likeDislikeMap = hashMapOf()
|
||||
@@ -559,6 +573,11 @@ class StatePolycentric {
|
||||
};
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
_backgroundJob.cancel()
|
||||
_commentPool.shutdown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StatePolycentric";
|
||||
|
||||
|
||||
@@ -57,9 +57,12 @@ class StateSync {
|
||||
return
|
||||
}
|
||||
|
||||
var relayServerUrl = Settings.instance.synchronization.syncServer;
|
||||
Logger.i(TAG, "Relay used: ${relayServerUrl}");
|
||||
|
||||
syncService = SyncService(
|
||||
SERVICE_NAME,
|
||||
RELAY_SERVER,
|
||||
relayServerUrl,
|
||||
RELAY_PUBLIC_KEY,
|
||||
APP_ID,
|
||||
StoreBasedSyncDatabaseProvider(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
|
||||
class ToggleBar : LinearLayout {
|
||||
private val _tagsContainer: LinearLayout;
|
||||
|
||||
private var allowLongPress: Boolean = false;
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
}
|
||||
@@ -48,12 +51,31 @@ class ToggleBar : LinearLayout {
|
||||
for(button in buttons) {
|
||||
_tagsContainer.addView(ToggleTagView(context).apply {
|
||||
if(button.icon > 0)
|
||||
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
|
||||
this.setInfo(button.icon, button.name, button.isActive, button.isButton, button.tag);
|
||||
else if(button.iconVariable != null)
|
||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
|
||||
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag);
|
||||
else
|
||||
this.setInfo(button.name, button.isActive, button.isButton);
|
||||
this.setInfo(button.name, button.isActive, button.isButton, button.tag);
|
||||
this.onClick.subscribe({ view, enabled -> button.action(view, enabled); });
|
||||
if(allowLongPress) {
|
||||
this.onLongClick.subscribe({ view, enabled ->
|
||||
for (tagView in _tagsContainer.children.filter { it is ToggleTagView }) {
|
||||
if (tagView != view && tagView is ToggleTagView && !tagView.isButton) {
|
||||
if (enabled && !tagView.isActive) {
|
||||
tagView.handleClick();
|
||||
} else if (!enabled && tagView.isActive) {
|
||||
tagView.handleClick();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
else if(button.actionLong != null) {
|
||||
this.onLongClick.subscribe({ view, enabled ->
|
||||
val tags = _tagsContainer.children.filter { it is ToggleTagView }.map { it as ToggleTagView }.toList();
|
||||
button.actionLong!!(view, tags, enabled);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,16 +85,18 @@ class ToggleBar : LinearLayout {
|
||||
val icon: Int;
|
||||
val iconVariable: ImageVariable?;
|
||||
val action: (ToggleTagView, Boolean)->Unit;
|
||||
val actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean) -> Unit)?;
|
||||
val isActive: Boolean;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
var tag: String? = null;
|
||||
|
||||
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit, actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean)->Unit)? = null) {
|
||||
this.name = name;
|
||||
this.icon = 0;
|
||||
this.iconVariable = icon;
|
||||
this.action = action;
|
||||
this.actionLong = actionLong;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, icon: Int, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
@@ -80,6 +104,7 @@ class ToggleBar : LinearLayout {
|
||||
this.icon = icon;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.actionLong = null;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
constructor(name: String, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
|
||||
@@ -87,6 +112,7 @@ class ToggleBar : LinearLayout {
|
||||
this.icon = 0;
|
||||
this.iconVariable = null;
|
||||
this.action = action;
|
||||
this.actionLong = null;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,21 +4,19 @@ import android.graphics.drawable.Animatable
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.CastProtocolType
|
||||
import com.futo.platformplayer.casting.CastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import androidx.core.view.isVisible
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
|
||||
class DeviceViewHolder : ViewHolder {
|
||||
private val _layoutDevice: FrameLayout;
|
||||
@@ -56,16 +54,18 @@ class DeviceViewHolder : ViewHolder {
|
||||
|
||||
val connect = {
|
||||
device?.let { dev ->
|
||||
if (dev.isReady) {
|
||||
StateCasting.instance.activeDevice?.stopCasting()
|
||||
StateCasting.instance.connectDevice(dev)
|
||||
onConnect.emit(dev)
|
||||
} else {
|
||||
try {
|
||||
view.context?.let { UIDialogs.toast(it, "Device not ready, may be offline") }
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
try {
|
||||
if (dev.isReady) {
|
||||
StateCasting.instance.activeDevice?.stopPlayback()
|
||||
StateCasting.instance.connectDevice(dev)
|
||||
onConnect.emit(dev)
|
||||
} else {
|
||||
view.context?.let {
|
||||
UIDialogs.toast(it, "Device not ready, may be offline")
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to connect: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,15 +81,25 @@ class DeviceViewHolder : ViewHolder {
|
||||
}
|
||||
|
||||
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
|
||||
if (d is ChromecastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
} else if (d is AirPlayCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
} else if (d is FCastCastingDevice) {
|
||||
_imageDevice.setImageResource(R.drawable.ic_fc);
|
||||
_textType.text = "FCast";
|
||||
when (d.protocolType) {
|
||||
CastProtocolType.CHROMECAST -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_chromecast);
|
||||
_textType.text = "Chromecast";
|
||||
}
|
||||
CastProtocolType.AIRPLAY -> {
|
||||
_imageDevice.setImageResource(R.drawable.ic_airplay);
|
||||
_textType.text = "AirPlay";
|
||||
}
|
||||
CastProtocolType.FCAST -> {
|
||||
_imageDevice.setImageResource(
|
||||
if (Settings.instance.casting.experimentalCasting) {
|
||||
R.drawable.ic_exp_fc
|
||||
} else {
|
||||
R.drawable.ic_fc
|
||||
}
|
||||
)
|
||||
_textType.text = "FCast";
|
||||
}
|
||||
}
|
||||
|
||||
_textName.text = d.name;
|
||||
@@ -136,4 +146,8 @@ class DeviceViewHolder : ViewHolder {
|
||||
|
||||
device = d;
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "DeviceViewHolder"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,7 @@ package com.futo.platformplayer.views.casting
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
|
||||
@@ -21,14 +21,13 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.api.media.models.chapters.IChapter
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
|
||||
import com.futo.platformplayer.casting.AirPlayCastingDevice
|
||||
import com.futo.platformplayer.casting.ChromecastCastingDevice
|
||||
import com.futo.platformplayer.casting.CastConnectionState
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.formatDuration
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||
import com.futo.platformplayer.views.behavior.GestureControlView
|
||||
@@ -36,7 +35,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CastView : ConstraintLayout {
|
||||
@@ -99,19 +97,30 @@ class CastView : ConstraintLayout {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
_speedHoldWasPlaying = d.isPlaying
|
||||
_speedHoldPrevRate = d.speed
|
||||
if (d.canSetSpeed)
|
||||
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
|
||||
d.resumeVideo()
|
||||
try {
|
||||
if (d.canSetSpeed()) {
|
||||
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
|
||||
}
|
||||
d.resumePlayback()
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change playback speed to hold playback speed: $e")
|
||||
}
|
||||
}
|
||||
_gestureControlView.onSpeedHoldEnd.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
if (!_speedHoldWasPlaying) d.pauseVideo()
|
||||
d.changeSpeed(_speedHoldPrevRate)
|
||||
try {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
if (!_speedHoldWasPlaying) {
|
||||
d.pausePlayback()
|
||||
}
|
||||
d.changeSpeed(_speedHoldPrevRate)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to change playback speed to previous hold playback speed: $e")
|
||||
}
|
||||
}
|
||||
|
||||
_gestureControlView.onSeek.subscribe {
|
||||
val d = StateCasting.instance.activeDevice ?: return@subscribe;
|
||||
StateCasting.instance.videoSeekTo(d.expectedCurrentTime + it / 1000);
|
||||
StateCasting.instance.videoSeekTo( d.expectedCurrentTime + it / 1000);
|
||||
};
|
||||
|
||||
_buttonLoop.setOnClickListener {
|
||||
@@ -220,22 +229,9 @@ class CastView : ConstraintLayout {
|
||||
stopTimeJob()
|
||||
|
||||
if(isPlaying) {
|
||||
val d = StateCasting.instance.activeDevice;
|
||||
if (d is AirPlayCastingDevice || d is ChromecastCastingDevice) {
|
||||
_updateTimeJob = _scope.launch {
|
||||
while (true) {
|
||||
val device = StateCasting.instance.activeDevice;
|
||||
if (device == null || !device.isPlaying) {
|
||||
break;
|
||||
}
|
||||
|
||||
delay(1000);
|
||||
val time_ms = (device.expectedCurrentTime * 1000.0).toLong()
|
||||
setTime(time_ms);
|
||||
onTimeJobTimeChanged_s.emit(device.expectedCurrentTime.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
StateCasting.instance.startUpdateTimeJob(
|
||||
onTimeJobTimeChanged_s
|
||||
) { setTime(it) }
|
||||
|
||||
if (!_inPictureInPicture) {
|
||||
_buttonPause.visibility = View.VISIBLE;
|
||||
@@ -333,4 +329,8 @@ class CastView : ConstraintLayout {
|
||||
_loaderGame.visibility = View.VISIBLE
|
||||
_loaderGame.startLoader(expectedDurationMs.toLong())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "CastView";
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import java.lang.reflect.Field
|
||||
|
||||
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
|
||||
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class AdvancedField();
|
||||
|
||||
|
||||
@@ -50,6 +50,29 @@ class RadioGroupView : FlexboxLayout {
|
||||
radioView.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
radioView.setInfo(option.first, initiallySelectedOptions.contains(option.second));
|
||||
radioView.setPadding(_padding_px, _padding_px, _padding_px, _padding_px);
|
||||
if(multiSelect)
|
||||
radioView.onLongClick.subscribe {
|
||||
val selected = !radioView.selected;
|
||||
if (selected) {
|
||||
selectedOptions.clear();
|
||||
for(v in radioViews)
|
||||
v.setIsSelected(true);
|
||||
selectedOptions.addAll(options.map { it.second });
|
||||
} else {
|
||||
if(atLeastOne) {
|
||||
for(v in radioViews)
|
||||
v.setIsSelected(false);
|
||||
selectedOptions.clear();
|
||||
selectedOptions.add(option.second);
|
||||
}
|
||||
else {
|
||||
for(v in radioViews)
|
||||
v.setIsSelected(false);
|
||||
selectedOptions.clear();
|
||||
}
|
||||
}
|
||||
onSelectedChange.emit(selectedOptions);
|
||||
}
|
||||
radioView.onClick.subscribe {
|
||||
val selected = !radioView.selected;
|
||||
if (selected) {
|
||||
|
||||
@@ -20,6 +20,7 @@ class RadioView : LinearLayout {
|
||||
|
||||
val selected get() = _selected;
|
||||
var onClick = Event0();
|
||||
var onLongClick = Event0();
|
||||
var onSelectedChange = Event1<Boolean>();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
@@ -32,6 +33,13 @@ class RadioView : LinearLayout {
|
||||
setIsSelected(!_selected)
|
||||
}
|
||||
};
|
||||
_root.setOnLongClickListener {
|
||||
onLongClick.emit();
|
||||
if (_handleClick) {
|
||||
setIsSelected(!_selected)
|
||||
}
|
||||
return@setOnLongClickListener true;
|
||||
}
|
||||
|
||||
_root.setBackgroundResource(R.drawable.background_radio_unselected);
|
||||
_textTag.setTextColor(ContextCompat.getColor(context, R.color.gray_67));
|
||||
|
||||
@@ -23,12 +23,16 @@ class ToggleTagView : LinearLayout {
|
||||
private var _text: String = "";
|
||||
private var _image: ImageView;
|
||||
|
||||
var tag: String? = null
|
||||
private set;
|
||||
|
||||
var isActive: Boolean = false
|
||||
private set;
|
||||
var isButton: Boolean = false
|
||||
private set;
|
||||
|
||||
var onClick = Event2<ToggleTagView, Boolean>();
|
||||
var onLongClick = Event2<ToggleTagView, Boolean>();
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_toggle_tag, this, true);
|
||||
@@ -36,10 +40,25 @@ class ToggleTagView : LinearLayout {
|
||||
_textTag = findViewById(R.id.text_tag);
|
||||
_image = findViewById(R.id.image_tag);
|
||||
_root.setOnClickListener {
|
||||
if(!isButton)
|
||||
setToggle(!isActive);
|
||||
onClick.emit(this, isActive);
|
||||
handleClick();
|
||||
}
|
||||
_root.setOnLongClickListener {
|
||||
if(onLongClick.hasListeners())
|
||||
onLongClick.emit(this, isActive);
|
||||
else {
|
||||
if(!isButton) {
|
||||
setToggle(!isActive);
|
||||
}
|
||||
onClick.emit(this, isActive);
|
||||
}
|
||||
return@setOnLongClickListener true;
|
||||
}
|
||||
}
|
||||
|
||||
fun handleClick() {
|
||||
if(!isButton)
|
||||
setToggle(!isActive);
|
||||
onClick.emit(this, isActive);
|
||||
}
|
||||
|
||||
fun setToggle(isActive: Boolean) {
|
||||
@@ -70,9 +89,10 @@ class ToggleTagView : LinearLayout {
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!toggle.name.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
tag = toggle.tag;
|
||||
}
|
||||
|
||||
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
@@ -80,8 +100,9 @@ class ToggleTagView : LinearLayout {
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
this.tag = tag;
|
||||
}
|
||||
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
setToggle(isActive);
|
||||
@@ -89,13 +110,15 @@ class ToggleTagView : LinearLayout {
|
||||
_image.visibility = View.VISIBLE;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
this.isButton = isButton;
|
||||
this.tag = tag;
|
||||
}
|
||||
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
|
||||
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false, tag: String? = null) {
|
||||
_image.visibility = View.GONE;
|
||||
_text = text;
|
||||
_textTag.text = text;
|
||||
_textTag.visibility = if(!text.isNullOrEmpty()) View.VISIBLE else View.GONE;
|
||||
setToggle(isActive);
|
||||
this.isButton = isButton;
|
||||
this.tag = tag;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import androidx.annotation.OptIn
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
@@ -66,6 +68,11 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
|
||||
videoView = findViewById(R.id.short_player_view)
|
||||
progressBar = findViewById(R.id.short_player_progress_bar)
|
||||
|
||||
if(Settings.instance.playback.shortsFitVideo)
|
||||
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT;
|
||||
else
|
||||
videoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM;
|
||||
|
||||
videoView.subtitleView?.setFixedTextSize(Dimension.SP, 18F);
|
||||
|
||||
if (!isInEditMode) {
|
||||
|
||||
@@ -907,11 +907,14 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
override fun switchToVideoMode() {
|
||||
super.switchToVideoMode()
|
||||
setArtwork(null)
|
||||
//setArtwork(null)
|
||||
}
|
||||
|
||||
override fun switchToAudioMode(video: IPlatformVideoDetails?) {
|
||||
super.switchToAudioMode(video)
|
||||
|
||||
//This causes issues, and is in general confusing, needs improvements
|
||||
/*
|
||||
val thumbnail = video?.thumbnails?.getHQThumbnail()
|
||||
if (!thumbnail.isNullOrBlank()) {
|
||||
Glide.with(context).asBitmap().load(thumbnail)
|
||||
@@ -928,5 +931,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,12 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManif
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
|
||||
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioFileSource
|
||||
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
|
||||
@@ -480,6 +485,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
|
||||
is IVideoUrlWidevineSource -> { swapVideoSourceUrlWidevine(videoSource); true; }
|
||||
is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
|
||||
is LocalVideoFileSource -> { swapVideoSourceLocalFile(videoSource); true; }
|
||||
is LocalVideoContentSource -> { swapVideoSourceLocalContent(videoSource); true; }
|
||||
null -> { _lastVideoMediaSource = null; true;}
|
||||
else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
|
||||
}
|
||||
@@ -496,6 +503,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume, swapId);
|
||||
is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
|
||||
is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
|
||||
is LocalAudioFileSource -> { swapAudioSourceLocalFile(audioSource); true; }
|
||||
is LocalAudioContentSource -> { swapAudioSourceLocalContent(audioSource); true; }
|
||||
null -> { _lastAudioMediaSource = null; true; }
|
||||
else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
|
||||
}
|
||||
@@ -514,6 +523,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceLocalFile(videoSource: LocalVideoFileSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Local]");
|
||||
val file = videoSource.file;
|
||||
if(!file.exists())
|
||||
throw IllegalArgumentException("File for this video does not exist");
|
||||
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceLocalContent(videoSource: LocalVideoContentSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Local]");
|
||||
if(!videoSource.contentUrl.startsWith("content://"))
|
||||
throw IllegalArgumentException("Not a content uri");
|
||||
_lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
|
||||
.createMediaSource(MediaItem.fromUri(videoSource.contentUrl));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) {
|
||||
Logger.i(TAG, "Loading JSVideoUrlRangeSource");
|
||||
if(videoSource.hasItag) {
|
||||
@@ -618,7 +644,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
val generated = generatedDef.awaitCancelConverted();
|
||||
if (_swapIdVideo.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
@@ -707,6 +733,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceLocalFile(audioSource: LocalAudioFileSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Local]");
|
||||
val file = audioSource.file;
|
||||
if(!file.exists())
|
||||
throw IllegalArgumentException("File for this video does not exist");
|
||||
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(file)));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceLocalContent(audioSource: LocalAudioContentSource) {
|
||||
Logger.i(TAG, "Loading VideoSource [Local]");
|
||||
if(!audioSource.contentUrl.startsWith("content://"))
|
||||
throw IllegalArgumentException("Not a content uri");
|
||||
_lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context))
|
||||
.createMediaSource(MediaItem.fromUri(audioSource.contentUrl));
|
||||
}
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) {
|
||||
Logger.i(TAG, "Loading JSAudioUrlRangeSource");
|
||||
if(audioSource.hasItag) {
|
||||
@@ -765,7 +808,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
setLoading(true)
|
||||
}
|
||||
}
|
||||
val generated = generatedDef.await();
|
||||
val generated = generatedDef.awaitCancelConverted();
|
||||
if (_swapIdAudio.get() != swapId) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="111.96dp"
|
||||
android:height="114.46dp"
|
||||
android:viewportWidth="111.96"
|
||||
android:viewportHeight="114.46">
|
||||
<path
|
||||
android:pathData="m84.76,5.58c2.06,-2.06 0.6,-5.58 -2.31,-5.58H3.27C1.46,-0 -0,1.46 -0,3.27V82.45c0,2.91 3.52,4.37 5.58,2.31L20.37,69.98c0.61,-0.61 0.96,-1.45 0.96,-2.31V24.6c0,-1.81 1.46,-3.27 3.27,-3.27h43.07c0.87,0 1.7,-0.34 2.31,-0.96z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="m45.68,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,69.57c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L48.38,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,51.16v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,51.16c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM67.73,73.5v17.18c0,0.68 -0.55,1.23 -1.23,1.23L49.61,91.9c-0.68,0 -1.23,-0.55 -1.23,-1.23v-17.18c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM89.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23h-16.89c-0.68,0 -1.23,-0.55 -1.23,-1.23L70.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,28.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM45.68,95.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L27.56,114.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L26.33,95.83c0,-0.68 0.55,-1.23 1.23,-1.23h16.89c0.68,0 1.23,0.55 1.23,1.23zM111.77,28.83v17.18c0,0.68 -0.55,1.23 -1.23,1.23L93.65,47.24c-0.68,0 -1.23,-0.55 -1.23,-1.23L92.43,28.83c0,-0.68 0.55,-1.23 1.23,-1.23L110.55,27.6c0.68,0 1.23,0.55 1.23,1.23z"
|
||||
android:strokeWidth="0"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
@@ -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
|
||||
|
||||
@@ -63,6 +63,16 @@
|
||||
android:layout_height="wrap_content"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/dialog_text_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:visibility="gone"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/dialog_buttons"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
android:layout_above="@+id/short_player_progress_bar"
|
||||
android:background="@color/black"
|
||||
app:default_artwork="@drawable/placeholder_video_thumbnail"
|
||||
app:resize_mode="fill"
|
||||
app:resize_mode="zoom"
|
||||
app:show_buffering="when_playing"
|
||||
app:use_artwork="true"
|
||||
app:use_controller="false" />
|
||||
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>النظام</item>
|
||||
<item>الإنجليزية (EN)</item>
|
||||
<item>الألمانية (DE)</item>
|
||||
<item>الإسبانية (ES)</item>
|
||||
<item>البرتغالية (PT)</item>
|
||||
<item>الفرنسية (FR)</item>
|
||||
<item>اليابانية (JA)</item>
|
||||
<item>الكورية (KO)</item>
|
||||
<item>الصينية (ZH)</item>
|
||||
<item>الروسية (RU)</item>
|
||||
<item>العربية (AR)</item>
|
||||
<item>الإيطالية (IT)</item>
|
||||
<item>التركية (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>System</item>
|
||||
<item>Englisch (EN)</item>
|
||||
<item>Deutsch (DE)</item>
|
||||
<item>Spanisch (ES)</item>
|
||||
<item>Portugiesisch (PT)</item>
|
||||
<item>Französisch (FR)</item>
|
||||
<item>Japanisch (JA)</item>
|
||||
<item>Koreanisch (KO)</item>
|
||||
<item>Chinesisch (ZH)</item>
|
||||
<item>Russisch (RU)</item>
|
||||
<item>Arabisch (AR)</item>
|
||||
<item>Italienisch (IT)</item>
|
||||
<item>Türkisch (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -714,4 +714,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>Sistema</item>
|
||||
<item>Inglés (EN)</item>
|
||||
<item>Alemán (DE)</item>
|
||||
<item>Español (ES)</item>
|
||||
<item>Portugués (PT)</item>
|
||||
<item>Francés (FR)</item>
|
||||
<item>Japonés (JA)</item>
|
||||
<item>Coreano (KO)</item>
|
||||
<item>Chino (ZH)</item>
|
||||
<item>Ruso (RU)</item>
|
||||
<item>Árabe (AR)</item>
|
||||
<item>Italiano (IT)</item>
|
||||
<item>Turco (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -712,4 +712,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>Système</item>
|
||||
<item>Anglais (EN)</item>
|
||||
<item>Allemand (DE)</item>
|
||||
<item>Espagnol (ES)</item>
|
||||
<item>Portugais (PT)</item>
|
||||
<item>Français (FR)</item>
|
||||
<item>Japonais (JA)</item>
|
||||
<item>Coréen (KO)</item>
|
||||
<item>Chinois (ZH)</item>
|
||||
<item>Russe (RU)</item>
|
||||
<item>Arabe (AR)</item>
|
||||
<item>Italien (IT)</item>
|
||||
<item>Turc (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>システム</item>
|
||||
<item>英語 (EN)</item>
|
||||
<item>ドイツ語 (DE)</item>
|
||||
<item>スペイン語 (ES)</item>
|
||||
<item>ポルトガル語 (PT)</item>
|
||||
<item>フランス語 (FR)</item>
|
||||
<item>日本語 (JA)</item>
|
||||
<item>韓国語 (KO)</item>
|
||||
<item>中国語 (ZH)</item>
|
||||
<item>ロシア語 (RU)</item>
|
||||
<item>アラビア語 (AR)</item>
|
||||
<item>イタリア語 (IT)</item>
|
||||
<item>トルコ語 (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>시스템</item>
|
||||
<item>영어 (EN)</item>
|
||||
<item>독일어 (DE)</item>
|
||||
<item>스페인어 (ES)</item>
|
||||
<item>포르투갈어 (PT)</item>
|
||||
<item>프랑스어 (FR)</item>
|
||||
<item>일본어 (JA)</item>
|
||||
<item>한국어 (KO)</item>
|
||||
<item>중국어 (ZH)</item>
|
||||
<item>러시아어 (RU)</item>
|
||||
<item>아랍어 (AR)</item>
|
||||
<item>이탈리아어 (IT)</item>
|
||||
<item>터키어 (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>Sistema</item>
|
||||
<item>Inglês (EN)</item>
|
||||
<item>Alemão (DE)</item>
|
||||
<item>Espanhol (ES)</item>
|
||||
<item>Português (PT)</item>
|
||||
<item>Francês (FR)</item>
|
||||
<item>Japonês (JA)</item>
|
||||
<item>Coreano (KO)</item>
|
||||
<item>Chinês (ZH)</item>
|
||||
<item>Russo (RU)</item>
|
||||
<item>Árabe (AR)</item>
|
||||
<item>Italiano (IT)</item>
|
||||
<item>Turco (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>Система</item>
|
||||
<item>Английский (EN)</item>
|
||||
<item>Немецкий (DE)</item>
|
||||
<item>Испанский (ES)</item>
|
||||
<item>Португальский (PT)</item>
|
||||
<item>Французский (FR)</item>
|
||||
<item>Японский (JA)</item>
|
||||
<item>Корейский (KO)</item>
|
||||
<item>Китайский (ZH)</item>
|
||||
<item>Русский (RU)</item>
|
||||
<item>Арабский (AR)</item>
|
||||
<item>Итальянский (IT)</item>
|
||||
<item>Турецкий (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -704,4 +704,19 @@
|
||||
<item>Newest</item>
|
||||
<item>Oldest</item>
|
||||
</string-array>
|
||||
<string-array name="app_languages">
|
||||
<item>系统</item>
|
||||
<item>英语 (EN)</item>
|
||||
<item>德语 (DE)</item>
|
||||
<item>西班牙语 (ES)</item>
|
||||
<item>葡萄牙语 (PT)</item>
|
||||
<item>法语 (FR)</item>
|
||||
<item>日语 (JA)</item>
|
||||
<item>韩语 (KO)</item>
|
||||
<item>中文 (ZH)</item>
|
||||
<item>俄语 (RU)</item>
|
||||
<item>阿拉伯语 (AR)</item>
|
||||
<item>意大利语 (IT)</item>
|
||||
<item>土耳其语 (TR)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
<string name="allow_ipv6_description">If casting over IPV6 is allowed, can cause issues on some networks</string>
|
||||
<string name="allow_ipv4">Allow Link Local IPV4</string>
|
||||
<string name="allow_ipv4_description">If casting over IPV4 link local is allowed, can cause issues on some networks</string>
|
||||
<string name="experimental_cast">Experimental</string>
|
||||
<string name="experimental_cast_description">Use experimental casting backend (requires restart)</string>
|
||||
<string name="discover">Discover</string>
|
||||
<string name="find_new_video_sources_to_add">Find new video sources to add</string>
|
||||
<string name="these_sources_have_been_disabled">These sources have been disabled</string>
|
||||
@@ -247,6 +249,7 @@
|
||||
<string name="membership">Membership</string>
|
||||
<string name="store">Store</string>
|
||||
<string name="live_chat">Live Chat</string>
|
||||
<string name="vod_chat">VOD Chat</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="space_videos">Videos</string>
|
||||
<string name="playlist">Playlist</string>
|
||||
@@ -337,6 +340,8 @@
|
||||
<string name="test_background_worker">Test Background Worker</string>
|
||||
<string name="test_background_worker_description"></string>
|
||||
<string name="clear_payment">Clear Payment</string>
|
||||
<string name="configure_sync_server">Configure Sync Server</string>
|
||||
<string name="configure_sync_server_description">Allows you to change the Sync Server to a self-hosted one.</string>
|
||||
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
|
||||
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
|
||||
<string name="configure_browsing_behavior">Configure browsing behavior</string>
|
||||
@@ -437,6 +442,9 @@
|
||||
<string name="delete_watchlist_on_finish_description">After you leave a video that you mostly watched, it will be removed from watch later.</string>
|
||||
<string name="shorts_pregenerate">Pre-generate shorts sources</string>
|
||||
<string name="shorts_pregenerate_description">Generates short sources (when applicable) one video ahead</string>
|
||||
<string name="shorts_fit_video">Fit Shorts Video</string>
|
||||
<string name="shorts_fit_video_description">Will scale the video to fit the view, instead of filling the view properly.</string>
|
||||
<string name="shorts_fit_video_warning">This setting will require you to reboot Grayjay.</string>
|
||||
<string name="seek_offset">Seek duration</string>
|
||||
<string name="min_playback_speed">Minimum Playback Speed</string>
|
||||
<string name="min_playback_speed_description">Minimum Available Speed</string>
|
||||
@@ -472,6 +480,7 @@
|
||||
<string name="number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources">Number of concurrent threads to multiply download speeds from throttled sources</string>
|
||||
<string name="payment">Payment</string>
|
||||
<string name="payment_status">Payment Status</string>
|
||||
<string name="relay_server">Sync Relay Server</string>
|
||||
<string name="bypass_rotation_prevention">Bypass Rotation Prevention</string>
|
||||
<string name="playlist_delete_confirmation">Playlist Delete Confirmation</string>
|
||||
<string name="playlist_delete_confirmation_description">Show confirmation dialog when deleting media from a playlist</string>
|
||||
@@ -1051,6 +1060,8 @@
|
||||
<item>Chinese (ZH)</item>
|
||||
<item>Russian (RU)</item>
|
||||
<item>Arabic (AR)</item>
|
||||
<item>Italian (IT)</item>
|
||||
<item>Turkish (TR)</item>
|
||||
</string-array>
|
||||
<string-array name="player_background_behavior">
|
||||
<item>None</item>
|
||||
@@ -1095,6 +1106,10 @@
|
||||
<item>ChromeCast</item>
|
||||
<item>AirPlay</item>
|
||||
</string-array>
|
||||
<string-array name="exp_casting_device_type_array" translatable="false">
|
||||
<item>FCast</item>
|
||||
<item>ChromeCast</item>
|
||||
</string-array>
|
||||
<string-array name="log_levels">
|
||||
<item>None</item>
|
||||
<item>Error</item>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Submodule app/src/stable/assets/sources/bilibili updated: 1222638042...f636e9713d
Submodule app/src/stable/assets/sources/odysee updated: 736c6b953a...89ad7e9a4b
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user