Compare commits

..

2 Commits

158 changed files with 2284 additions and 7176 deletions
+47 -51
View File
@@ -1,8 +1,8 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
id 'org.ajoberstar.grgit' version '5.3.3'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
id 'org.ajoberstar.grgit' version '5.2.2'
id 'com.google.protobuf'
id 'kotlin-parcelize'
id 'com.google.devtools.ksp'
@@ -39,7 +39,7 @@ protobuf {
android {
namespace 'com.futo.platformplayer'
compileSdk 36
compileSdk 34
flavorDimensions "buildType"
productFlavors {
stable {
@@ -97,7 +97,7 @@ android {
defaultConfig {
minSdk 28
targetSdk 36
targetSdk 34
versionCode gitVersionCode
versionName gitVersionName
@@ -154,85 +154,81 @@ android {
}
dependencies {
//implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.8.0'
implementation 'com.google.android.material:material:1.13.0'
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
implementation 'com.google.dagger:dagger:2.48'
implementation 'androidx.test:monitor:1.7.2'
implementation 'com.google.android.material:material:1.12.0'
annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
//Core
implementation 'androidx.core:core-ktx:1.17.0'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
//Images
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
implementation 'com.github.bumptech.glide:glide:5.0.5'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
//Async
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
//HTTP
implementation "com.squareup.okhttp3:okhttp:5.3.0"
implementation "com.squareup.okhttp3:okhttp:4.11.0"
//JSON
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
//JS
implementation 'com.caoccao.javet:javet-v8-android:5.0.1'
implementation("com.caoccao.javet:javet-android:3.0.2")
//implementation 'com.caoccao.javet:javet-v8-android:4.1.4' //Change after extensive testing the freezing edge cases are solved.
//Exoplayer
implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-exoplayer-dash:1.8.0'
implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.8.0'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.8.0'
implementation 'androidx.media3:media3-transformer:1.8.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
implementation 'androidx.media:media:1.7.1'
implementation 'androidx.media3:media3-exoplayer:1.2.1'
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
implementation 'androidx.media3:media3-ui:1.2.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
implementation 'androidx.media3:media3-transformer:1.2.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
implementation 'androidx.media:media:1.7.0'
//Other
implementation 'org.jsoup:jsoup:1.21.2'
implementation 'org.jsoup:jsoup:1.15.3'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.google.zxing:core:3.4.1'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation 'com.caverock:androidsvg-aar:1.4'
//Protobuf
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
implementation 'com.polycentric.core:app:1.0'
implementation 'com.futo.futopay:app:1.0'
implementation 'androidx.work:work-runtime-ktx:2.11.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
implementation 'androidx.work:work-runtime-ktx:2.9.0'
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
//Database
implementation("androidx.room:room-runtime:2.8.3")
ksp("androidx.room:room-compiler:2.8.3")
implementation("androidx.room:room-ktx:2.8.3")
implementation("androidx.room:room-runtime:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
//Payment
implementation 'com.stripe:stripe-android:22.0.0'
implementation 'com.stripe:stripe-android:20.35.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
testImplementation "org.mockito:mockito-core:5.20.0"
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
//Rust casting SDK
implementation('org.futo.gitlab.videostreaming.fcast-sdk-jitpack:sender-sdk-minimal:0.3.1') {
// Polycentricandroid includes this
exclude group: 'net.java.dev.jna'
}
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
testImplementation "org.mockito:mockito-core:5.4.0"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
+20 -24
View File
@@ -153,30 +153,30 @@
</activity>
<activity
android:name=".activities.TestActivity"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SettingsActivity"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.DeveloperActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ExceptionActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.CaptchaActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.LoginActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.AddSourceActivity"
android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
android:theme="@style/Theme.FutoVideo.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@@ -189,58 +189,54 @@
<activity
android:name=".activities.AddSourceOptionsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricHomeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricBackupActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricCreateProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricWhyActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.PolycentricImportProfileActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.ManageTabsActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.QRCaptureActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.FCastGuideActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncHomeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncPairActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
<activity
android:name=".activities.SyncShowPairingCodeActivity"
android:screenOrientation="sensorPortrait"
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
<activity
android:name=".activities.PolycentricModerationActivity"
android:exported="false"
android:screenOrientation="portrait" />
android:theme="@style/Theme.FutoVideo.NoActionBar" />
</application>
</manifest>
+2 -22
View File
@@ -1022,35 +1022,15 @@
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] = settingsToUse;
parameterVals[1] = __DEV_SETTINGS;
else
parameterVals.push(settingsToUse);
parameterVals.push(__DEV_SETTINGS);
}
const func = source[name];
-1
View File
@@ -67,7 +67,6 @@ class ScriptException extends Error {
super(arguments[0]);
this.plugin_type = "ScriptException";
this.message = arguments[0];
this.msg = arguments[0];
}
else {
super(msg);
@@ -216,9 +216,10 @@ private fun ByteArray.toInetAddress(): InetAddress {
return InetAddress.getByAddress(this);
}
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int): 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")})");
@@ -231,7 +232,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
val socket = Socket()
try {
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
} catch (e: Throwable) {
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
socket.close()
@@ -262,7 +263,7 @@ fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs
}
}
socket.connect(InetSocketAddress(address, port), timeoutMs);
socket.connect(InetSocketAddress(address, port), timeout);
synchronized(syncObject) {
if (connectedSocket == null) {
@@ -7,13 +7,11 @@ 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
@@ -23,6 +21,7 @@ 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
@@ -195,6 +194,7 @@ 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,19 +204,16 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
override fun onFulfilled(p0: V8Value?) {
if(p0 is V8ValueError)
promiseException = ScriptExecutionException(plugin.config, p0.message);
else {
if(p0 is V8ValueObject)
p0.setWeak();
else
promiseResult = p0 as T;
}
latch.countDown();
}
override fun onRejected(p0: V8Value?) {
promiseException = p0?.toException(plugin.config);
promiseException = (NotImplementedError("onRejected promise not implemented.."));
latch.countDown();
}
override fun onCatch(p0: V8Value?) {
promiseException = p0?.toException(plugin.config);
promiseException = (NotImplementedError("onCatch promise not implemented.."));
latch.countDown();
}
});
@@ -226,25 +223,8 @@ fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
promiseException = CancellationException("Cancelled by system");
latch.countDown();
}
//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();
}
plugin.unbusy {
latch.await();
}
if(promiseException != null)
throw promiseException!!;
@@ -269,25 +249,12 @@ fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T>
underlyingDef.complete(p0 as T);
}
override fun onRejected(p0: V8Value?) {
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);
}
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onRejected promise not implemented.."));
}
override fun onCatch(p0: V8Value?) {
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);
}
plugin.resolvePromise(promise);
underlyingDef.completeExceptionally(NotImplementedError("onCatch promise not implemented.."));
}
});
}
@@ -298,23 +265,6 @@ 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>{
@@ -375,16 +325,4 @@ 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;
}
}
@@ -1,118 +0,0 @@
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,7 +25,6 @@ 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
@@ -35,7 +34,6 @@ 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
@@ -203,8 +201,6 @@ class Settings : FragmentedStorageFileJson() {
8 -> "zh";
9 -> "ru";
10 -> "ar";
11 -> "it";
12 -> "tr";
else -> null
}
}
@@ -408,10 +404,6 @@ class Settings : FragmentedStorageFileJson() {
else -> null
}
}
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
var stickySubtitles: Boolean = true;
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
var preferOriginalAudio: Boolean = true;
@@ -611,16 +603,6 @@ class Settings : FragmentedStorageFileJson() {
else -> 2.0
}
}
@AdvancedField
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
var shortsPregenerate: Boolean = false;
@AdvancedField
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
@FormFieldWarning(R.string.shorts_fit_video_warning)
var shortsFitVideo: Boolean = false;
}
@FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -723,11 +705,6 @@ 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)
@@ -1110,39 +1087,6 @@ 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.withInput(it.text, { str ->
it.invokeAction(str);
return@map Action(it.text, {
it.action();
multiShowDialog(context, dialogDescriptor.drop(1), finally);
}, it.style);
}.toTypedArray());
@@ -203,9 +203,7 @@ 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
= 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 {
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, 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);
@@ -228,16 +226,6 @@ 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 {
@@ -262,7 +250,7 @@ class UIDialogs {
buttonView.textSize = 14f;
buttonView.typeface = resources.getFont(R.font.inter_regular);
buttonView.text = act.text;
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
when(act.style) {
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
@@ -287,7 +275,7 @@ class UIDialogs {
};
dialog.setOnCancelListener {
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
actions[defaultCloseAction].action();
}
dialog.setOnDismissListener {
registerDialogClosed(dialog);
@@ -547,36 +535,17 @@ class UIDialogs {
}
class Action {
val text: String;
val action: ((DialogResult?)->Unit);
val action: ()->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,
@@ -107,9 +107,10 @@ class AddSourceActivity : AppCompatActivity() {
onNewIntent(intent);
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
var url = intent.dataString;
var url = intent?.dataString;
if(url == null)
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
@@ -15,7 +15,6 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.matchesDomain
import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
@@ -75,26 +74,9 @@ class LoginActivity : AppCompatActivity() {
finish();
};
var isFirstLoad = true;
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
var currentScale = 100;
var currentDesktop = false;
webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(loginWarnings.size > 0 && url != null) {
synchronized(loginWarnings) {
val warning = loginWarnings.find { url.matches(it.getRegex()) };
if(warning != null) {
if(warning.once == true)
loginWarnings.remove(warning);
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
UIDialogs.Action("Understood", {
}, UIDialogs.ActionStyle.PRIMARY));
}
}
}
if(!isFirstLoad)
return@subscribe;
isFirstLoad = false;
@@ -104,35 +86,6 @@ class LoginActivity : AppCompatActivity() {
//TODO: Find most reliable way to wait for page js to finish
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
}
/*
var specifiedScale = false;
var specifiedDesktop = false;
if(uiMods.size > 0 && url != null) {
synchronized(uiMods) {
val uimod = uiMods.find { url.matches(it.getRegex()) };
if(uimod != null) {
if(uimod.scale != null) {
currentScale =(uimod.scale * 100).toInt();
_webView.setInitialScale(currentScale);
specifiedScale = true;
}
if(uimod.desktop != null && uimod.desktop) {
_webView.settings.useWideViewPort = true;
specifiedDesktop = true;
}
}
}
}
if(!specifiedScale && currentScale != 100) {
currentScale = (100).toInt();
_webView.setInitialScale(currentScale);
}
if(!specifiedDesktop && currentDesktop) {
_webView.settings.useWideViewPort = false;
currentDesktop = false;
}
*/
}
_webView.settings.domStorageEnabled = true;
@@ -16,6 +16,7 @@ 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
@@ -35,11 +36,9 @@ 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
@@ -199,7 +198,6 @@ 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)
@@ -285,6 +283,9 @@ 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 {
@@ -299,9 +300,6 @@ 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);
@@ -412,11 +410,6 @@ 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 {
@@ -645,11 +638,6 @@ 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,
@@ -708,13 +696,17 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
_wasStopped = true;
}
override fun onNewIntent(intent: Intent) {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent);
handleIntent(intent);
}
private fun handleIntent(intent: Intent) {
private fun handleIntent(intent: Intent?) {
if (intent == null)
return;
Logger.i(TAG, "handleIntent started by " + intent.action);
var targetData: String? = null;
when (intent.action) {
@@ -776,7 +768,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if (targetData != null) {
lifecycleScope.launch(Dispatchers.Main) {
try {
handleUrlAll(targetData, intent)
handleUrlAll(targetData)
} catch (e: Throwable) {
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
}
@@ -787,9 +779,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}
}
suspend fun handleUrlAll(url: String, openIntent: Intent? = null) {
suspend fun handleUrlAll(url: String) {
val uri = Uri.parse(url)
val intent = openIntent ?: this.intent;
when (uri.scheme) {
"grayjay" -> {
if (url.startsWith("grayjay://license/")) {
@@ -816,11 +807,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",
{ });
}
@@ -941,12 +932,6 @@ 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;
}
@@ -1061,7 +1046,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
Logger.i(TAG, "handleFCast");
try {
StateCasting.instance.handleUrl(url)
StateCasting.instance.handleUrl(this, url)
return true;
} catch (e: Throwable) {
Log.e(TAG, "Failed to parse FCast URL '${url}'.", e)
@@ -1,147 +0,0 @@
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 _buttonModeration: BigButton;
private lateinit var _buttonOpenHarborProfile: 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);
_buttonModeration = findViewById(R.id.button_moderation);
_buttonOpenHarborProfile = findViewById(R.id.button_open_harbor_profile);
_buttonLogout = findViewById(R.id.button_logout);
_buttonDelete = findViewById(R.id.button_delete);
_loaderOverlay = findViewById(R.id.loader_overlay);
@@ -99,9 +99,15 @@ class PolycentricProfileActivity : AppCompatActivity() {
startActivity(Intent(this, PolycentricBackupActivity::class.java));
};
_buttonModeration.onClick.subscribe {
startActivity(Intent(this, PolycentricModerationActivity::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)))
}
}
_buttonLogout.onClick.subscribe {
StatePolycentric.instance.setProcessHandle(null);
@@ -110,19 +110,7 @@ class SyncPairActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
try {
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}')")
StateSync.instance.syncService?.connect(deviceInfo) { complete, message ->
lifecycleScope.launch(Dispatchers.Main) {
if (complete != null) {
if (complete) {
@@ -54,16 +54,14 @@ interface IPlatformChannelContent : IPlatformContent {
val subscribers: Long?
}
open class JSChannelContent(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformChannelContent {
open class JSChannelContent : JSContent, IPlatformChannelContent {
override val contentType: ContentType get() = ContentType.CHANNEL
override val thumbnail: String?
override val subscribers: Long?
final override val contentType: ContentType = ContentType.CHANNEL
override val thumbnail: String? =
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
override val subscribers: Long? =
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
}
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Channel";
thumbnail = obj.getOrDefault<String>(config, "thumbnail", contextName, null)
subscribers = if(obj.has("subscribers")) obj.getOrThrow(config,"subscribers", contextName) else null
}
}
@@ -6,15 +6,25 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.structures.IPager
import java.time.OffsetDateTime
open class PlatformComment(
override val contextUrl: String,
override val author: PlatformAuthorLink,
override val message: String,
override val rating: IRating,
override val date: OffsetDateTime,
override val replyCount: Int? = null
) : IPlatformComment {
open class PlatformComment : IPlatformComment {
override val contextUrl: String;
override val author: PlatformAuthorLink;
override val message: String;
override val rating: IRating;
override val date: OffsetDateTime;
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
NoCommentsPager()
}
override val replyCount: Int?;
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) {
this.contextUrl = contextUrl;
this.author = author;
this.message = msg;
this.rating = rating;
this.date = date;
this.replyCount = replyCount;
}
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
return NoCommentsPager();
}
}
@@ -2,24 +2,10 @@ 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 : 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;
}
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();
}
@@ -14,8 +14,7 @@ class AudioUrlSource(
override val language: String = Language.UNKNOWN,
override val duration: Long? = null,
override var priority: Boolean = false,
override var original: Boolean = false,
var isLocal: Boolean = false
override var original: Boolean = false
) : IAudioUrlSource, IStreamMetaDataSource{
override var streamMetaData: StreamMetaData? = null;
@@ -41,7 +41,6 @@ class HLSVariantSubtitleUrlSource(
override val format: String,
) : ISubtitleSource {
override val hasFetch: Boolean = false
override val language: String? = null
override fun getSubtitles(): String? {
return null
@@ -9,15 +9,13 @@ class LocalSubtitleSource : ISubtitleSource {
override val name: String;
override val url: String?;
override val format: String?;
override val language: String?
override val hasFetch: Boolean get() = false;
val filePath: String;
constructor(name: String, language: String?, format: String?, filePath: String) {
constructor(name: String, format: String?, filePath: String) {
this.name = name;
this.format = format;
this.language = language
this.filePath = filePath;
this.url = Uri.fromFile(File(filePath)).toString();
}
@@ -34,7 +32,6 @@ class LocalSubtitleSource : ISubtitleSource {
fun fromSource(source: SubtitleRawSource, path: String): LocalSubtitleSource {
return LocalSubtitleSource(
source.name,
source.language,
source.format,
path
);
@@ -6,7 +6,6 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
@kotlinx.serialization.Serializable
class SubtitleRawSource(
override val name: String,
override val language: String?,
override val format: String?,
val _subtitles: String,
override val url: String? = null,
@@ -14,8 +14,7 @@ open class VideoUrlSource(
override val codec : String = "",
override val bitrate : Int? = 0,
override var priority: Boolean = false,
var isLocal: Boolean = false
override var priority: Boolean = false
) : IVideoUrlSource, IStreamMetaDataSource {
override var streamMetaData: StreamMetaData? = null;
@@ -7,7 +7,6 @@ interface ISubtitleSource {
val url: String?;
val format: String?;
val hasFetch: Boolean;
val language: String?
fun getSubtitles(): String?;
@@ -1,122 +0,0 @@
package com.futo.platformplayer.api.media.models.video
import android.annotation.SuppressLint
import android.net.Uri
import android.provider.MediaStore
import android.provider.OpenableColumns
import androidx.core.net.toUri
import com.futo.platformplayer.api.media.IPlatformClient
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
import com.futo.platformplayer.api.media.models.ratings.IRating
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.LocalVideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.AudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.LocalVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.SubtitleRawSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.api.media.platforms.local.models.LocalVideoMuxedSourceDescriptor
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalAudioContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoContentSource
import com.futo.platformplayer.api.media.platforms.local.models.sources.LocalVideoFileSource
import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StateApp
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class LocalVideoDetails(
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
override val author: PlatformAuthorLink,
override val url: String,
override val duration: Long,
val mimeType: String? = null,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?
) : IPlatformVideo, IPlatformVideoDetails {
final override val contentType: ContentType get() = ContentType.MEDIA;
override var playbackTime: Long = -1;
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override var playbackDate: OffsetDateTime? = null;
override val isLive: Boolean get() = false;
override val dash: IDashManifestSource? get() = null;
override val hls: IHLSManifestSource? get() = null;
override val live: IVideoSource? get() = null;
override val shareUrl: String = ""
override val viewCount: Long = -1
override val rating: IRating = RatingLikes(0)
override val description: String = "";
override val video: IVideoSourceDescriptor = (if(mimeType?.startsWith("audio/") ?: false)
(LocalVideoUnMuxedSourceDescriptor(
arrayOf(),
arrayOf(LocalAudioContentSource(url, mimeType ?: "", name))
))
else (LocalVideoMuxedSourceDescriptor(
LocalVideoContentSource(url, mimeType ?: "", name)
))
);
override val preview: ISerializedVideoSourceDescriptor? = null;
override val subtitles: List<SubtitleRawSource> = listOf()
override val isShort: Boolean = false
fun toJson() : String {
return Json.encodeToString(this);
}
fun fromJson(str : String) : SerializedPlatformVideoDetails {
return Serializer.json.decodeFromString<SerializedPlatformVideoDetails>(str);
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
override fun getPlaybackTracker(): IPlaybackTracker? = null;
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
companion object {
fun fromFile(name: String, filePath: String, mimeType: String? = null) : LocalVideoDetails {
if(filePath.startsWith("content://"))
return fromContent(filePath, mimeType);
return LocalVideoDetails(PlatformID("FILE", filePath, null, 0, -1),
name, Thumbnails(), PlatformAuthorLink.UNKNOWN, filePath, -1, mimeType, null);
}
fun fromContent(contentUrl: String, mimeType: String? = null) : LocalVideoDetails {
var nameToUse = getFileNameFromContentUrl(contentUrl) ?: "File";
return LocalVideoDetails(PlatformID("FILE", contentUrl, null, 0, -1),
nameToUse, Thumbnails(), PlatformAuthorLink.UNKNOWN, contentUrl, -1, mimeType, null);
}
@SuppressLint("Range")
private fun getFileNameFromContentUrl(url: String): String? {
val cursor = StateApp.instance.context.contentResolver.query(url.toUri(), null, null, null, null);
cursor?.moveToFirst();
val fileName = cursor?.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
cursor?.close();
return fileName;
}
}
}
@@ -103,7 +103,7 @@ open class JSClient : IPlatformClient {
override val id: String get() = config.id;
override val name: String get() = config.name;
override val icon: ImageVariable get() = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null)
override val icon: ImageVariable;
override var capabilities: PlatformClientCapabilities = PlatformClientCapabilities();
private var _busyAction = "";
@@ -147,6 +147,7 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String? = null) {
this._context = context;
this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor;
_injectedSaveState = saveState;
_auth = descriptor.getAuth();
@@ -177,6 +178,7 @@ open class JSClient : IPlatformClient {
constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String, withoutCredentials: Boolean = false) {
this._context = context;
this.config = descriptor.config;
icon = StatePlatform.instance.getPlatformIcon(config.id) ?: ImageVariable(config.absoluteIconUrl, null, null);
this.descriptor = descriptor;
_injectedSaveState = saveState;
if(!withoutCredentials)
@@ -1,10 +1,6 @@
package com.futo.platformplayer.api.media.platforms.js
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.util.Dictionary
@Serializable
@kotlinx.serialization.Serializable
class SourcePluginAuthConfig(
val loginUrl: String,
val completionUrl: String? = null,
@@ -15,44 +11,5 @@ class SourcePluginAuthConfig(
val userAgent: String? = null,
val loginButton: String? = null,
val domainHeadersToFind: Map<String, List<String>>? = null,
val loginWarning: String? = null,
val loginWarnings: List<Warning>? = null,
val uiMods: List<UIMod>? = null
) {
@Serializable
class Warning(
val url: String,
val text: String?,
val details: String? = null,
val once: Boolean? = true
) {
@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);
_regex = reg;
return reg;
}
}
}
}
val loginWarning: String? = null
) { }
@@ -23,22 +23,17 @@ import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.states.StateDeveloper
open class JSArticle(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformArticle, IPluginSourced {
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
final override val contentType: ContentType get() = ContentType.ARTICLE;
final override val contentType: ContentType = ContentType.ARTICLE
override val summary: String;
override val thumbnails: Thumbnails?;
override val summary: String =
obj.getOrDefault<String>(config, "summary", "PlatformArticle", "") ?: ""
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformArticle";
override val thumbnails: Thumbnails? =
if (obj.has("thumbnails"))
Thumbnails.fromV8(
config,
obj.getOrThrow<V8ValueObject>(config, "thumbnails", "PlatformArticle")
)
else
null
}
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
}
}
@@ -24,37 +24,36 @@ import com.futo.platformplayer.getOrThrowNullableList
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.states.StateDeveloper
open class JSArticleDetails(
private val client: JSClient,
obj: V8ValueObject
) : JSContent(client.config, obj), IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
final override val contentType: ContentType get() = ContentType.ARTICLE;
final override val contentType: ContentType = ContentType.ARTICLE
private val _hasGetComments: Boolean;
private val _hasGetContentRecommendations: Boolean;
private val _hasGetComments: Boolean = _content.has("getComments")
private val _hasGetContentRecommendations: Boolean = _content.has("getContentRecommendations")
override val rating: IRating;
override val rating: IRating =
obj.getOrDefault<V8ValueObject>(client.config, "rating", "PlatformArticle", null)
?.let { IRating.fromV8(client.config, it, "PlatformArticle") }
?: RatingLikes(0)
override val summary: String;
override val thumbnails: Thumbnails?;
override val segments: List<IJSArticleSegment>;
override val summary: String =
_content.getOrThrow(client.config, "summary", "PlatformArticle")
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
val contextName = "PlatformArticle";
override val thumbnails: Thumbnails? =
if (_content.has("thumbnails"))
Thumbnails.fromV8(
client.config,
_content.getOrThrow(client.config, "thumbnails", "PlatformArticle")
)
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
summary = _content.getOrThrow(client.config, "summary", contextName);
if(_content.has("thumbnails"))
thumbnails = Thumbnails.fromV8(client.config, _content.getOrThrow(client.config, "thumbnails", contextName));
else
null
thumbnails = null;
override val segments: List<IJSArticleSegment> =
obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", "PlatformArticle")
?.mapNotNull { fromV8Segment(client, it) }
?: emptyList()
segments = (obj.getOrThrowNullableList<V8ValueObject>(client.config, "segments", contextName)
?.map { fromV8Segment(client, it) }
?.filterNotNull() ?: listOf());
_hasGetComments = _content.has("getComments");
_hasGetContentRecommendations = _content.has("getContentRecommendations");
}
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? {
if(!_hasGetComments || _content.isClosed)
@@ -16,49 +16,51 @@ import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
open class JSContent(
protected val _pluginConfig: SourcePluginConfig,
protected val _content: V8ValueObject
) : IPlatformContent, IPluginSourced {
open class JSContent : IPlatformContent, IPluginSourced {
protected val _pluginConfig: SourcePluginConfig;
protected val _content : V8ValueObject;
override val contentType: ContentType = ContentType.UNKNOWN
protected val _hasGetDetails: Boolean;
protected val _hasGetDetails: Boolean = _content.has("getDetails")
override val contentType: ContentType get() = ContentType.UNKNOWN;
override val id: PlatformID =
PlatformID.fromV8(_pluginConfig, _content.getOrThrow(_pluginConfig, "id", CTX))
override val id: PlatformID;
override val name: String;
override val author: PlatformAuthorLink;
override val datetime: OffsetDateTime?;
override val name: String =
HtmlCompat.fromHtml(
_content.getOrThrow<String>(_pluginConfig, "name", CTX).decodeUnicode(),
HtmlCompat.FROM_HTML_MODE_LEGACY
).toString()
override val url: String;
override val shareUrl: String;
override val author: PlatformAuthorLink =
_content.getOrDefault<V8ValueObject>(_pluginConfig, "author", CTX, null)
?.let { PlatformAuthorLink.fromV8(_pluginConfig, it) }
?: PlatformAuthorLink.UNKNOWN
override val sourceConfig: SourcePluginConfig get() = _pluginConfig;
private val _epoch: Long? =
_content.getOrDefault<Long>(_pluginConfig, "datetime", CTX, null)?.toLong()
constructor(config: SourcePluginConfig, obj: V8ValueObject) {
_pluginConfig = config;
_content = obj;
override val datetime: OffsetDateTime? =
_epoch?.takeIf { it != 0L }?.let {
OffsetDateTime.of(LocalDateTime.ofEpochSecond(it, 0, ZoneOffset.UTC), ZoneOffset.UTC)
}
val contextName = "PlatformContent";
override val url: String =
_content.getOrThrow<String>(_pluginConfig, "url", CTX)
id = PlatformID.fromV8(_pluginConfig, _content.getOrThrow(config, "id", contextName));
name = HtmlCompat.fromHtml(_content.getOrThrow<String>(config, "name", contextName).decodeUnicode(), HtmlCompat.FROM_HTML_MODE_LEGACY).toString();
override val shareUrl: String =
_content.getOrDefault<String>(_pluginConfig, "shareUrl", CTX, null) ?: url
val authorObj = _content.getOrDefault<V8ValueObject>(config, "author", contextName, null);
if(authorObj != null)
author = PlatformAuthorLink.fromV8(_pluginConfig, authorObj);
else
author = PlatformAuthorLink.UNKNOWN;
override val sourceConfig: SourcePluginConfig
get() = _pluginConfig
val datetimeInt = _content.getOrDefault<Int>(config, "datetime", contextName, null)?.toLong();
if(datetimeInt == null || datetimeInt == 0.toLong())
datetime = null;
else
datetime = OffsetDateTime.of(LocalDateTime.ofEpochSecond(datetimeInt, 0, ZoneOffset.UTC), ZoneOffset.UTC);
url = _content.getOrThrow(config, "url", contextName);
shareUrl = _content.getOrDefault<String>(config, "shareUrl", contextName, null) ?: url;
fun getUnderlyingObject(): V8ValueObject? = _content
companion object {
private const val CTX = "PlatformContent"
_hasGetDetails = _content.has("getDetails");
}
}
fun getUnderlyingObject(): V8ValueObject? {
return _content;
}
}
@@ -6,16 +6,14 @@ import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
open class JSPlaylist(
config: SourcePluginConfig,
obj: V8ValueObject
) : JSContent(config, obj), IPlatformPlaylist {
open class JSPlaylist : JSContent, IPlatformPlaylist {
override val contentType: ContentType get() = ContentType.PLAYLIST;
override val thumbnail: String?;
override val videoCount: Int;
override val contentType: ContentType = ContentType.PLAYLIST
override val thumbnail: String? =
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Playlist", null)
override val videoCount: Int =
_content.getOrDefault<Int>(_pluginConfig, "videoCount", "Playlist", null)?.toInt() ?: -1
}
constructor(config: SourcePluginConfig, obj: V8ValueObject) : super(config, obj) {
val contextName = "Playlist";
thumbnail = obj.getOrDefault(config, "thumbnail", contextName, null);
videoCount = obj.getOrDefault(config, "videoCount", contextName, -1)!!;
}
}
@@ -22,7 +22,6 @@ class JSSubtitleSource : ISubtitleSource {
override val name: String;
override val url: String?;
override val format: String?;
override val language: String?
override val hasFetch: Boolean;
constructor(config: SourcePluginConfig, v8Value: V8ValueObject) {
@@ -30,7 +29,6 @@ class JSSubtitleSource : ISubtitleSource {
val context = "JSSubtitles";
name = v8Value.getOrThrow(config, "name", context, false);
language = v8Value.getOrThrow(config, "language", context, false);
url = v8Value.getOrThrow(config, "url", context, true);
format = v8Value.getOrThrow(config, "format", context, true);
hasFetch = v8Value.has("getSubtitles");
@@ -8,44 +8,43 @@ import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
open class JSAudioUrlSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_AUDIOURL, plugin, obj), IAudioUrlSource {
open class JSAudioUrlSource : IAudioUrlSource, JSSource {
override val name: String;
override val bitrate : Int;
override val container : String;
override val codec: String;
private val url : String;
private val ctx = "AudioUrlSource"
private val cfg = plugin.config
override val language: String;
override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override val duration: Long?;
override val container: String =
_obj.getOrThrow<String>(cfg, "container", ctx)
override var priority: Boolean = false;
override val codec: String =
_obj.getOrThrow<String>(cfg, "codec", ctx)
override var original: Boolean = false;
private val url: String =
_obj.getOrThrow<String>(cfg, "url", ctx)
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_AUDIOURL, plugin, obj) {
val contextName = "AudioUrlSource";
val config = plugin.config;
override val language: String =
_obj.getOrThrow<String>(cfg, "language", ctx)
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
container = _obj.getOrThrow(config, "container", contextName);
codec = _obj.getOrThrow(config, "codec", contextName);
url = _obj.getOrThrow(config, "url", contextName);
language = _obj.getOrThrow(config, "language", contextName);
duration = _obj.getOrDefault(config, "duration", contextName, null);
override val duration: Long? =
_obj.getOrDefault<Long>(cfg, "duration", ctx, null)?.toLong()
name = _obj.getOrDefault(config, "name", contextName, "${container} ${bitrate}") ?: "${container} ${bitrate}";
override val name: String =
_obj.getOrDefault<String>(cfg, "name", ctx, null)
?: "$container $bitrate"
priority = if(_obj.has("priority")) obj.getOrThrow(config, "priority", contextName) else false;
original = if(_obj.has("original")) obj.getOrThrow(config, "original", contextName) else false;
}
override var priority: Boolean =
if (_obj.has("priority")) _obj.getOrThrow<Boolean>(cfg, "priority", ctx) else false
override fun getAudioUrl() : String {
return url;
}
override var original: Boolean =
if (_obj.has("original")) _obj.getOrThrow<Boolean>(cfg, "original", ctx) else false
override fun getAudioUrl(): String = url
override fun toString(): String =
"(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)"
}
override fun toString(): String {
return "(name=$name, container=$container, bitrate=$bitrate, codec=$codec, url=$url, language=$language, duration=$duration)";
}
}
@@ -17,7 +17,6 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.Language
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
@@ -58,24 +57,12 @@ class JSDashManifestRawAudioSource : JSSource, IAudioSource, IJSDashManifestRawS
hasGenerate = _obj.has("generate");
}
private var _pregenerate: V8Deferred<String?>? = null;
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
if(!hasGenerate)
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawAudioSource", "Returning pre-generated audio");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
var result: V8Deferred<V8ValueString>? = null;
@@ -18,7 +18,6 @@ import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.invokeV8
import com.futo.platformplayer.invokeV8Async
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateDeveloper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -31,55 +30,39 @@ interface IJSDashManifestRawSource {
fun generateAsync(scope: CoroutineScope): Deferred<String?>;
fun generate(): String?;
}
open class JSDashManifestRawSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_DASH_RAW, plugin, obj), IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
open class JSDashManifestRawSource: JSSource, IVideoSource, IJSDashManifestRawSource, IStreamMetaDataSource {
override val container : String;
override val name : String;
override val width: Int;
override val height: Int;
override val codec: String;
override val bitrate: Int?;
override val duration: Long;
override val priority: Boolean;
private val ctx = "DashRawSource"
private val cfg = plugin.config
val url: String?;
override var manifest: String?;
override val container: String =
_obj.getOrDefault<String>(cfg, "container", ctx, null) ?: "application/dash+xml"
override val hasGenerate: Boolean;
val canMerge: Boolean;
override val name: String =
_obj.getOrThrow<String>(cfg, "name", ctx)
override var streamMetaData: StreamMetaData? = null;
override val width: Int =
_obj.getOrDefault<Int>(cfg, "width", ctx, null)?.toInt() ?: 0
override val height: Int =
_obj.getOrDefault<Int>(cfg, "height", ctx, null)?.toInt() ?: 0
override val codec: String =
_obj.getOrDefault<String>(cfg, "codec", ctx, "") ?: ""
override val bitrate: Int? =
_obj.getOrDefault<Int>(cfg, "bitrate", ctx, null)?.toInt()
override val duration: Long =
_obj.getOrDefault<Long>(cfg, "duration", ctx, 0)?.toLong() ?: 0L
override val priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
val url: String? =
_obj.getOrDefault<String>(cfg, "url", ctx, null)
override var manifest: String? =
_obj.getOrDefault<String>(cfg, "manifest", ctx, null)
override val hasGenerate: Boolean = _obj.has("generate")
val canMerge: Boolean =
_obj.getOrDefault<Boolean>(cfg, "canMerge", ctx, false) ?: false
override var streamMetaData: StreamMetaData? = null
private var _pregenerate: V8Deferred<String?>? = null
fun pregenerateAsync(scope: CoroutineScope): V8Deferred<String?>? {
_pregenerate = generateAsync(scope);
return _pregenerate;
constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
val contextName = "DashRawSource";
val config = plugin.config;
name = _obj.getOrThrow(config, "name", contextName);
url = _obj.getOrThrow(config, "url", contextName);
container = _obj.getOrDefault<String>(config, "container", contextName, null) ?: "application/dash+xml";
manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
hasGenerate = _obj.has("generate");
}
override fun generateAsync(scope: CoroutineScope): V8Deferred<String?> {
@@ -87,11 +70,6 @@ open class JSDashManifestRawSource(
return V8Deferred(CompletableDeferred(manifest));
if(_obj.isClosed)
throw IllegalStateException("Source object already closed");
val pregenerated = _pregenerate;
if(pregenerated != null) {
Logger.w("JSDashManifestRawSource", "Returning pre-generated video");
return pregenerated;
}
val plugin = _plugin.getUnderlyingPlugin();
@@ -5,47 +5,42 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrNull
import com.futo.platformplayer.getOrThrow
open class JSVideoUrlSource(
plugin: JSClient,
obj: V8ValueObject
) : JSSource(TYPE_VIDEOURL, plugin, obj), IVideoUrlSource {
open class JSVideoUrlSource : IVideoUrlSource, JSSource {
override val width : Int;
override val height : Int;
override val container : String;
override val codec: String;
override val name : String;
override val bitrate : Int;
override val duration: Long;
private val url : String;
private val ctx = "JSVideoUrlSource"
private val cfg = plugin.config
override var priority: Boolean = false;
override val width: Int =
_obj.getOrThrow<Int>(cfg, "width", ctx)
constructor(plugin: JSClient, obj: V8ValueObject): super(TYPE_VIDEOURL, plugin, obj) {
val contextName = "JSVideoUrlSource";
val config = plugin.config;
override val height: Int =
_obj.getOrThrow<Int>(cfg, "height", ctx)
width = _obj.getOrThrow(config, "width", contextName);
height = _obj.getOrThrow(config, "height", contextName);
container = _obj.getOrThrow(config, "container", contextName);
codec = _obj.getOrThrow(config, "codec", contextName);
name = _obj.getOrThrow(config, "name", contextName);
bitrate = _obj.getOrThrow(config, "bitrate", contextName);
duration = _obj.getOrThrow<Int>(config, "duration", contextName).toLong();
url = _obj.getOrThrow(config, "url", contextName);
override val container: String =
_obj.getOrThrow<String>(cfg, "container", ctx)
priority = obj.getOrNull(config, "priority", contextName) ?: false;
}
override val codec: String =
_obj.getOrThrow<String>(cfg, "codec", ctx)
override fun getVideoUrl() : String {
return url;
}
override val name: String =
_obj.getOrThrow<String>(cfg, "name", ctx)
override val bitrate: Int =
_obj.getOrThrow<Int>(cfg, "bitrate", ctx)
override val duration: Long =
_obj.getOrThrow<Long>(cfg, "duration", ctx)
private val url: String =
_obj.getOrThrow<String>(cfg, "url", ctx)
override var priority: Boolean =
_obj.getOrDefault<Boolean>(cfg, "priority", ctx, false) ?: false
override fun getVideoUrl(): String = url
override fun toString(): String =
"(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
}
override fun toString(): String {
return "(width=$width, height=$height, container=$container, codec=$codec, name=$name, bitrate=$bitrate, duration=$duration, url=$url)"
}
}
@@ -1,23 +1,13 @@
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: 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;
}
class LocalVideoMuxedSourceDescriptor(
private val video: LocalVideoFileSource
) : VideoMuxedSourceDescriptor() {
override val videoSources: Array<IVideoSource> get() = arrayOf(video);
}
@@ -1,33 +0,0 @@
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;
}
}
@@ -1,34 +0,0 @@
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;
}
}
@@ -1,33 +0,0 @@
package com.futo.platformplayer.api.media.platforms.local.models.sources
import android.content.Context
import android.provider.MediaStore
import android.provider.MediaStore.Video
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
import com.futo.platformplayer.helpers.VideoHelper
import java.io.File
class LocalVideoContentSource: IVideoSource {
override val name: String;
override val width: Int;
override val height: Int;
override val container: String;
override val codec: String = ""
override val bitrate: Int = 0
override val duration: Long;
override val priority: Boolean = false;
var contentUrl: String;
constructor(contentUrl: String, mime: String, name: String? = null) {
this.name = name ?: "File";
width = 0;
height = 0;
container = mime;
duration = 0;
this.contentUrl = contentUrl;
}
}
@@ -20,10 +20,7 @@ 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;
@@ -12,7 +12,7 @@ class MultiDistributionContentPager<T : IPlatformContent> : MultiPager<T> {
private val dist : HashMap<IPager<T>, Float>;
private val distConsumed : HashMap<IPager<T>, Float>;
constructor(pagers : Map<IPager<T>, Float>, pageSize: Int = 9) : super(pagers.keys.toMutableList(), false, pageSize) {
constructor(pagers : Map<IPager<T>, Float>) : super(pagers.keys.toMutableList()) {
val distTotal = pagers.values.sum();
dist = HashMap();
@@ -15,7 +15,7 @@ import kotlinx.coroutines.launch
import java.net.InetAddress
import java.util.UUID
class AirPlayCastingDevice : CastingDeviceLegacy {
class AirPlayCastingDevice : CastingDevice {
//See for more info: https://nto.github.io/AirPlay
override val protocol: CastProtocolType get() = CastProtocolType.AIRPLAY;
@@ -2,78 +2,147 @@ package com.futo.platformplayer.casting
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.models.CastingDeviceInfo
import org.fcast.sender_sdk.Metadata
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 java.net.InetAddress
abstract class CastingDevice {
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
@Throws
abstract fun resumePlayback()
@Throws
abstract fun pausePlayback()
@Throws
abstract fun stopPlayback()
@Throws
abstract fun seekTo(timeSeconds: Double)
@Throws
abstract fun changeVolume(timeSeconds: Double)
@Throws
abstract fun changeSpeed(speed: Double)
@Throws
abstract fun connect()
@Throws
abstract fun disconnect()
abstract fun getDeviceInfo(): CastingDeviceInfo
abstract fun getAddresses(): List<InetAddress>
@Throws
abstract fun loadVideo(
streamType: String,
contentType: String,
contentId: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
@Throws
abstract fun loadContent(
contentType: String,
content: String,
resumePosition: Double,
duration: Double,
speed: Double?,
metadata: Metadata?
)
abstract fun ensureThreadStarted()
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;
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>;
}
@@ -1,271 +0,0 @@
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"
}
}
@@ -1,242 +0,0 @@
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 : CastingDeviceLegacy {
class ChromecastCastingDevice : CastingDevice {
//See for more info: https://developers.google.com/cast/docs/media/messages
override val protocol: CastProtocolType get() = CastProtocolType.CHROMECAST;
@@ -3,6 +3,7 @@ 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
@@ -24,6 +25,7 @@ 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
@@ -32,6 +34,7 @@ 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
@@ -69,7 +72,7 @@ enum class Opcode(val value: Byte) {
}
}
class FCastCastingDevice : CastingDeviceLegacy {
class FCastCastingDevice : CastingDevice {
//See for more info: TODO
override val protocol: CastProtocolType get() = CastProtocolType.FCAST;
File diff suppressed because it is too large Load Diff
@@ -1,178 +0,0 @@
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"
}
}
@@ -1,399 +0,0 @@
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,13 +8,11 @@ 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) {
@@ -40,13 +38,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_buttonConfirm = findViewById(R.id.button_confirm);
_buttonTutorial = findViewById(R.id.button_tutorial)
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 ->
ArrayAdapter.createFromResource(context, R.array.casting_device_type_array, R.layout.spinner_item_simple).also { adapter ->
adapter.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
_spinnerType.adapter = adapter;
};
@@ -109,11 +101,7 @@ class CastingAddDialog(context: Context?) : AlertDialog(context) {
_textError.visibility = View.GONE;
val castingDeviceInfo = CastingDeviceInfo(name, castProtocolType, arrayOf(ip), port.toInt());
try {
StateCasting.instance.addRememberedDevice(castingDeviceInfo)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to add remembered device: $e")
}
StateCasting.instance.addRememberedDevice(castingDeviceInfo);
performDismiss();
};
@@ -7,6 +7,7 @@ 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
@@ -17,6 +18,7 @@ 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
@@ -106,16 +108,15 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
synchronized(StateCasting.instance.devices) {
_devices.addAll(StateCasting.instance.devices.values.mapNotNull { it.name })
}
_rememberedDevices.addAll(StateCasting.instance.getRememberedCastingDeviceNames())
_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,11 +12,12 @@ 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
@@ -68,18 +69,18 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonPlay = findViewById(R.id.button_play);
_buttonPlay.setOnClickListener {
StateCasting.instance.resumeVideo()
StateCasting.instance.activeDevice?.resumeVideo()
}
_buttonPause = findViewById(R.id.button_pause);
_buttonPause.setOnClickListener {
StateCasting.instance.pauseVideo()
StateCasting.instance.activeDevice?.pauseVideo()
}
_buttonStop = findViewById(R.id.button_stop);
_buttonStop.setOnClickListener {
(ownerActivity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
StateCasting.instance.stopVideo()
StateCasting.instance.activeDevice?.stopVideo()
}
_buttonNext = findViewById(R.id.button_next);
@@ -89,11 +90,7 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
_buttonClose.setOnClickListener { dismiss(); };
_buttonDisconnect.setOnClickListener {
try {
StateCasting.instance.activeDevice?.disconnect()
} catch (e: Throwable) {
Logger.e(TAG, "Active device failed to disconnect: $e")
}
StateCasting.instance.activeDevice?.stopCasting();
dismiss();
};
@@ -102,7 +99,12 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener
}
StateCasting.instance.videoSeekTo(value.toDouble())
val activeDevice = StateCasting.instance.activeDevice ?: return@OnChangeListener;
try {
activeDevice.seekVideo(value.toDouble());
} catch (e: Throwable) {
Logger.e(TAG, "Failed to change volume.", e);
}
});
//TODO: Check if volume slider is properly hidden in all cases
@@ -111,7 +113,14 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
return@OnChangeListener
}
StateCasting.instance.changeVolume(value.toDouble())
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);
}
}
});
setLoading(false);
@@ -163,25 +172,15 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
private fun updateDevice() {
val d = StateCasting.instance.activeDevice ?: return;
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";
}
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";
}
_textName.text = d.name;
@@ -193,7 +192,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 {
@@ -215,7 +214,8 @@ class ConnectedCastingDialog(context: Context?) : AlertDialog(context) {
CastConnectionState.CONNECTED -> {
enableControls(interactiveControls)
}
CastConnectionState.CONNECTING, CastConnectionState.DISCONNECTED -> {
CastConnectionState.CONNECTING,
CastConnectionState.DISCONNECTED -> {
disableControls(interactiveControls)
}
}
@@ -719,7 +719,7 @@ class VideoDownload {
Logger.i(TAG, "Download $name Dash, CueCount: " + foundCues.count().toString());
var written: Long = 0;
var written = 0;
var indexCounter = 0;
onProgress(foundCues.count().toLong(), 0, 0);
for(cue in foundCues) {
@@ -744,7 +744,7 @@ class VideoDownload {
indexCounter++;
}
sourceLength = written;
sourceLength = written.toLong();
Logger.i(TAG, "$name downloadSource Finished");
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.caoccao.javet.exceptions.JavetCompilationException
import com.caoccao.javet.exceptions.JavetException
import com.caoccao.javet.exceptions.JavetExecutionException
import com.caoccao.javet.interfaces.IJavetEntityError
import com.caoccao.javet.interfaces.IJavetEntityMap
import com.caoccao.javet.interop.V8Host
import com.caoccao.javet.interop.V8Runtime
import com.caoccao.javet.values.V8Value
@@ -20,7 +18,6 @@ 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
@@ -39,7 +36,6 @@ 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
@@ -246,12 +242,10 @@ 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();
}
/*
@@ -411,12 +405,6 @@ class V8Plugin {
return _runtimeMap.getOrDefault(runtime, null);
}
private fun ctxString(ctx: Any?, key: String): String? = when (ctx) {
is Map<*, *> -> ctx[key]?.toString()
is V8ValueObject -> if (ctx.has(key)) ctx.getString(key) else null
else -> null
}
fun <T: Any?> catchScriptErrors(config: IV8PluginConfig, context: String, code: String? = null, handle: ()->T): T {
var codeStripped = code;
if(codeStripped != null) { //TODO: Improve code stripped
@@ -450,6 +438,37 @@ class V8Plugin {
throw ScriptCompilationException(config, "Compilation: [${context}]: ${scriptEx.message}\n(${scriptEx.scriptingError.lineNumber})[${scriptEx.scriptingError.startColumn}-${scriptEx.scriptingError.endColumn}]: ${scriptEx.scriptingError.sourceLine}", null, codeStripped);
}
catch(executeEx: JavetExecutionException) {
val obj = executeEx.scriptingError?.context
if(obj != null && obj.containsKey("plugin_type") == true) {
val pluginType = obj["plugin_type"].toString();
//Captcha
if (pluginType == "CaptchaRequiredException") {
throw ScriptCaptchaRequiredException(config,
obj["url"]?.toString(),
obj["body"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Reload Required
if (pluginType == "ReloadRequiredException") {
throw ScriptReloadRequiredException(config,
obj["msg"]?.toString(),
obj["reloadData"]?.toString(),
executeEx, executeEx.scriptingError?.stack, codeStripped);
}
//Others
throwExceptionFromV8(
config,
pluginType,
(extractJSExceptionMessage(executeEx) ?: ""),
executeEx,
executeEx.scriptingError?.stack,
codeStripped
);
}
/* //Required for newer V8 versions
if(executeEx.scriptingError?.context is IJavetEntityError) {
val obj = executeEx.scriptingError?.context as IJavetEntityError
if(obj.context.containsKey("plugin_type") == true) {
@@ -483,6 +502,7 @@ class V8Plugin {
}
}
*/
throw ScriptExecutionException(config, extractJSExceptionMessage(executeEx) ?: "", null, executeEx.scriptingError?.stack, codeStripped);
}
catch(ex: Exception) {
@@ -491,29 +511,18 @@ 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" -> 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);
"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);
}
}
@@ -194,11 +194,7 @@ class PackageBridge : V8Package {
val stackTrace = Thread.currentThread().stackTrace;
val callerMethod = stackTrace.findLast {
it.className == JSClient::class.java.name &&
it.methodName != "isBusy" &&
it.methodName != "busy" &&
it.methodName != "getCopy" &&
it.methodName != "isBusyWith"
it.className == JSClient::class.java.name
}?.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(@Transient private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
class BatchBuilder(private val _package: PackageHttp, existingRequests: MutableList<Pair<PackageHttpClient, RequestDescriptor>> = mutableListOf()): V8BindObject() {
@Transient
private val _reqs = existingRequests;
@@ -279,14 +279,6 @@ 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())
@@ -1,28 +1,46 @@
package com.futo.platformplayer.fragment.mainactivity.main
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Bundle
import android.text.Spanned
import android.util.AttributeSet
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.SoundEffectConstants
import android.view.View
import android.view.animation.AccelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
@@ -36,30 +54,40 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
import com.futo.platformplayer.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.Event3
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.engine.exceptions.ScriptLoginRequiredException
import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.fragment.mainactivity.special.CommentsModalBottomSheet
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSize
import com.futo.platformplayer.views.buttons.ShortsButton
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.MonetizationView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuButtonList
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuGroup
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
@@ -67,17 +95,20 @@ import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTitle
import com.futo.platformplayer.views.pills.OnLikeDislikeUpdatedArgs
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.platformplayer.views.video.FutoShortPlayer
import com.futo.platformplayer.views.video.FutoVideoPlayerBase
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_AUDIO_CONTAINERS
import com.futo.platformplayer.views.video.FutoVideoPlayerBase.Companion.PREFERED_VIDEO_CONTAINERS
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.ContentType
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.Opinion
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
//import com.google.android.material.button.MaterialButton
import com.google.protobuf.ByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -85,29 +116,30 @@ import userpackage.Protocol
@UnstableApi
class ShortView : FrameLayout {
private lateinit var fragment: MainFragment
private lateinit var mainFragment: MainFragment
private val player: FutoShortPlayer
private val channelInfo: LinearLayout
private val creatorThumbnail: CreatorThumbnail
private val channelName: TextView
private val videoTitle: TextView
private val videoSubtitle: TextView
private val platformIndicator: PlatformIndicator
//TODO: Replace with non-material button
private val backButton: MaterialButton
private val backButtonContainer: ConstraintLayout
private val likeButton: ShortsButton
//private val likeCount: TextView
private val dislikeButton: ShortsButton
//private val dislikeCount: TextView
private val likeContainer: FrameLayout
private val dislikeContainer: FrameLayout
private val likeButton: MaterialButton
private val likeCount: TextView
private val dislikeButton: MaterialButton
private val dislikeCount: TextView
private val commentsButton: ShortsButton
private val shareButton: ShortsButton
private val refreshButton: ShortsButton
private val qualityButton: ShortsButton
private val commentsButton: MaterialButton
private val shareButton: MaterialButton
private val refreshButton: MaterialButton
private val refreshButtonContainer: View
private val qualityButton: MaterialButton
private val playPauseOverlay: FrameLayout
private val playPauseIcon: ImageView
@@ -141,21 +173,18 @@ class ShortView : FrameLayout {
private val onLikeDislikeUpdated = Event1<OnLikeDislikeUpdatedArgs>()
private val onVideoUpdated = Event1<IPlatformVideo?>()
//TODO: Replace with non-material UI? Only true dependency on Material left
private val bottomSheet: CommentsModalBottomSheet = CommentsModalBottomSheet()
var likes: Long = 0
set(value) {
field = value
likeButton.withPrimaryText(value.toString());
//likeCount.text = value.toString()
likeCount.text = value.toString()
}
var dislikes: Long = 0
set(value) {
field = value
dislikeButton.withPrimaryText(value.toString());
//dislikeCount.text = value.toString()
dislikeCount.text = value.toString()
}
constructor(inflater: LayoutInflater, fragment: MainFragment, overlayQualityContainer: FrameLayout) : this(inflater.context) {
@@ -165,7 +194,7 @@ class ShortView : FrameLayout {
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT
)
this.fragment = fragment
this.mainFragment = fragment
bottomSheet.mainFragment = fragment
}
@@ -188,17 +217,19 @@ class ShortView : FrameLayout {
creatorThumbnail = findViewById(R.id.creator_thumbnail)
channelName = findViewById(R.id.channel_name)
videoTitle = findViewById(R.id.video_title)
videoSubtitle = findViewById(R.id.video_subtitle)
platformIndicator = findViewById(R.id.short_platform_indicator)
backButton = findViewById(R.id.back_button)
backButtonContainer = findViewById(R.id.back_button_container)
likeContainer = findViewById(R.id.like_container)
dislikeContainer = findViewById(R.id.dislike_container)
likeButton = findViewById(R.id.like_button)
//likeCount = findViewById(R.id.like_count)
likeCount = findViewById(R.id.like_count)
dislikeButton = findViewById(R.id.dislike_button)
//dislikeCount = findViewById(R.id.dislike_count)
dislikeCount = findViewById(R.id.dislike_count)
commentsButton = findViewById(R.id.comments_button)
shareButton = findViewById(R.id.share_button)
refreshButton = findViewById(R.id.refresh_button)
refreshButtonContainer = findViewById(R.id.refresh_button_container)
qualityButton = findViewById(R.id.quality_button)
playPauseOverlay = findViewById(R.id.play_pause_overlay)
playPauseIcon = findViewById(R.id.play_pause_icon)
@@ -215,16 +246,6 @@ class ShortView : FrameLayout {
}
}
player.onPlayChanged.subscribe {
if (it) {
Logger.i(TAG, "Keep screen on set because isPlaying")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
Logger.i(TAG, "Keep screen on cleared because not isPlaying")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
onPlayingToggled.subscribe { playing ->
if (playing) {
playPauseIcon.setImageResource(R.drawable.ic_play)
@@ -237,44 +258,48 @@ class ShortView : FrameLayout {
}
onVideoUpdated.subscribe {
Logger.i(TAG, "Shorts videoUpdated [${it?.name}] (isDetail: ${it is IPlatformVideoDetails}, thumbnail: ${it?.author?.thumbnail})");
videoTitle.text = it?.name
videoSubtitle.text = if(it is IPlatformVideoDetails) it?.description; else "";
platformIndicator.setPlatformFromClientID(it?.id?.pluginId)
creatorThumbnail.setThumbnail(it?.author?.thumbnail, true)
channelName.text = it?.author?.name
}
backButton.setOnClickListener {
fragment.closeSegment()
playSoundEffect(SoundEffectConstants.CLICK)
mainFragment.closeSegment()
}
channelInfo.setOnClickListener {
fragment.navigate<ChannelFragment>(video?.author)
playSoundEffect(SoundEffectConstants.CLICK)
mainFragment.navigate<ChannelFragment>(video?.author)
}
videoTitle.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
if (!bottomSheet.isAdded) {
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG)
}
}
commentsButton.onClick.subscribe {
commentsButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
if (!bottomSheet.isAdded) {
bottomSheet.show(fragment.childFragmentManager, CommentsModalBottomSheet.TAG)
bottomSheet.show(mainFragment.childFragmentManager, CommentsModalBottomSheet.TAG)
}
}
shareButton.onClick.subscribe {
shareButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
val url = video?.shareUrl ?: video?.url
fragment.startActivity(Intent.createChooser(Intent().apply {
mainFragment.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, url)
type = "text/plain"
}, null))
}
refreshButton.onClick.subscribe {
refreshButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
onResetTriggered.emit()
}
@@ -283,12 +308,14 @@ class ShortView : FrameLayout {
false
}
qualityButton.onClick.subscribe {
qualityButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
showVideoSettings()
}
likeButton.onClick.subscribe {
val checked = likeButton.iconId == R.drawable.ic_thumb_up_s // !likeButton.isChecked
likeButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
val checked = !likeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) {
likes++
@@ -296,27 +323,24 @@ class ShortView : FrameLayout {
likes--
}
if(checked)
likeButton.withIcon(R.drawable.ic_thumb_up_s_filled) //.isChecked = checked
else
likeButton.withIcon(R.drawable.ic_thumb_up_s)
likeButton.isChecked = checked
if (dislikeButton.iconId == R.drawable.ic_thumb_down_s_filled && checked) {
//dislikeButton.isChecked = false
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
if (dislikeButton.isChecked && checked) {
dislikeButton.isChecked = false
dislikes--
}
onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs(
it, likes, checked, dislikes, !checked
it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked
)
)
}
}
dislikeButton.onClick.subscribe {
val checked = dislikeButton.iconId == R.drawable.ic_thumb_down_s //!dislikeButton.isChecked
dislikeButton.setOnClickListener {
playSoundEffect(SoundEffectConstants.CLICK)
val checked = !dislikeButton.isChecked
StatePolycentric.instance.requireLogin(context, context.getString(R.string.please_login_to_like)) {
if (checked) {
dislikes++
@@ -324,21 +348,16 @@ class ShortView : FrameLayout {
dislikes--
}
//dislikeButton.isChecked = checked
if(checked)
dislikeButton.withIcon(R.drawable.ic_thumb_down_s_filled) //.isChecked = checked
else
dislikeButton.withIcon(R.drawable.ic_thumb_down_s)
dislikeButton.isChecked = checked
if (likeButton.iconId == R.drawable.ic_thumb_up_s_filled && checked) {
//likeButton.isChecked = false
likeButton.withIcon(R.drawable.ic_thumb_up_s);
if (likeButton.isChecked && checked) {
likeButton.isChecked = false
likes--
}
onLikeDislikeUpdated.emit(
OnLikeDislikeUpdatedArgs(
it, likes, !checked, dislikes, checked
it, likes, likeButton.isChecked, dislikes, dislikeButton.isChecked
)
)
}
@@ -347,11 +366,11 @@ class ShortView : FrameLayout {
onLikesLoaded.subscribe(tag) { rating, liked, disliked ->
likes = rating.likes
dislikes = rating.dislikes
//likeButton.isChecked = liked
//dislikeButton.isChecked = disliked
likeButton.isChecked = liked
dislikeButton.isChecked = disliked
dislikeButton.visibility = VISIBLE
likeButton.visibility = VISIBLE
dislikeContainer.visibility = VISIBLE
likeContainer.visibility = VISIBLE
}
player.onPlaybackStateChanged.subscribe {
@@ -546,7 +565,7 @@ class ShortView : FrameLayout {
var toSet: ISubtitleSource? = subtitleSource
if (_lastSubtitleSource == subtitleSource) toSet = null
fragment.lifecycleScope.launch(Dispatchers.Main) {
mainFragment.lifecycleScope.launch(Dispatchers.Main) {
try {
player.swapSubtitles(toSet)
} catch (e: Throwable) {
@@ -606,7 +625,7 @@ class ShortView : FrameLayout {
@Suppress("unused")
fun setMainFragment(fragment: MainFragment, overlayQualityContainer: FrameLayout) {
this.fragment = fragment
this.mainFragment = fragment
this.bottomSheet.mainFragment = fragment
this.overlayQualityContainer = overlayQualityContainer
}
@@ -617,10 +636,10 @@ class ShortView : FrameLayout {
}
this.video = video
refreshButton.visibility = if (isChannelShortsMode) {
refreshButtonContainer.visibility = if (isChannelShortsMode) {
GONE
} else {
GONE //TODO: Revert?
VISIBLE
}
backButtonContainer.visibility = if (isChannelShortsMode) {
VISIBLE
@@ -676,8 +695,8 @@ class ShortView : FrameLayout {
}
private fun loadLikes(video: IPlatformVideo) {
likeButton.visibility = GONE
dislikeButton.visibility = GONE
likeContainer.visibility = GONE
dislikeContainer.visibility = GONE
loadLikesTask?.cancel()
loadLikesTask =
@@ -716,13 +735,13 @@ class ShortView : FrameLayout {
args.processHandle.opinion(ref, Opinion.neutral)
}
fragment.lifecycleScope.launch(Dispatchers.IO) {
mainFragment.lifecycleScope.launch(Dispatchers.IO) {
try {
Logger.i(TAG, "Started backfill")
Logger.i(CommentsModalBottomSheet.TAG, "Started backfill")
args.processHandle.fullyBackfillServersAnnounceExceptions()
Logger.i(TAG, "Finished backfill")
Logger.i(CommentsModalBottomSheet.TAG, "Finished backfill")
} catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e)
Logger.e(CommentsModalBottomSheet.TAG, "Failed to backfill servers", e)
}
}
@@ -744,41 +763,20 @@ class ShortView : FrameLayout {
setLoading(true)
Logger.i(TAG, "Shorts loadVideo [${url}]");
val timeLoadVideoStart = System.currentTimeMillis();
loadVideoTask = TaskHandler<String, IPlatformVideoDetails>(
StateApp.instance.scopeGetter, {
val result = StatePlatform.instance.getContentDetails(it).await()
if (result !is IPlatformVideoDetails) throw IllegalStateException("Expected media content, found ${result.contentType}")
return@TaskHandler result
}).success { result ->
val timeLoadVideo = System.currentTimeMillis() - timeLoadVideoStart;
Logger.i(TAG, "Shorts loadVideo [${url}] took ${timeLoadVideo}ms");
videoDetails = result
video = result
videoDetails = result
video = result
if(Settings.instance.playback.shortsPregenerate)
fragment.lifecycleScope.launch(Dispatchers.IO) {
if(result != null) {
val prefVid = VideoHelper.selectBestVideoSource(result.video, Settings.instance.playback.getCurrentPreferredQualityPixelCount(), PREFERED_VIDEO_CONTAINERS);
val prefAud = VideoHelper.selectBestAudioSource(result.video, PREFERED_AUDIO_CONTAINERS, Settings.instance.playback.getPrimaryLanguage(context));
bottomSheet.video = result
if(prefVid != null && prefVid is JSDashManifestRawSource) {
Logger.i(TAG, "Shorts pregenerating video (${result.name})");
prefVid.pregenerateAsync(fragment.lifecycleScope);
}
if(prefAud != null && prefAud is JSDashManifestRawAudioSource) {
Logger.i(TAG, "Shorts pregenerating audio (${result.name})");
prefAud.pregenerateAsync(fragment.lifecycleScope);
}
}
}
setLoading(false)
bottomSheet.video = result
setLoading(false)
if (playWhenReady) playVideo()
if (playWhenReady) playVideo()
}.exception<NoPlatformClientException> {
Logger.w(TAG, "exception<NoPlatformClientException>", it)
UIDialogs.showDialog(
@@ -801,7 +799,7 @@ class ShortView : FrameLayout {
UIDialogs.showSingleButtonDialog(context, R.drawable.ic_schedule, "Video is available in ${it.availableWhen}.", "Close") { }
}.exception<ScriptImplementationException> {
Logger.w(TAG, "exception<ScriptImplementationException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, fragment)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptimplementationexception), it, { loadVideo(url) }, null, mainFragment)
}.exception<ScriptAgeException> {
Logger.w(TAG, "exception<ScriptAgeException>", it)
UIDialogs.showDialog(
@@ -814,10 +812,10 @@ class ShortView : FrameLayout {
)
}.exception<ScriptException> {
Logger.w(TAG, "exception<ScriptException>", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, fragment)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video_scriptexception), it, { loadVideo(url) }, null, mainFragment)
}.exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load video.", it)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, fragment)
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_video), it, { loadVideo(url) }, null, mainFragment)
}
loadVideoTask?.run(url)
@@ -851,7 +849,6 @@ class ShortView : FrameLayout {
}
val thumbnail = videoDetails.thumbnails.getHQThumbnail()
/*
if (videoSource == null && !thumbnail.isNullOrBlank()) Glide.with(context).asBitmap()
.load(thumbnail).into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@@ -863,9 +860,8 @@ class ShortView : FrameLayout {
}
})
else player.setArtwork(null)
*/
fragment.lifecycleScope.launch(Dispatchers.Main) {
mainFragment.lifecycleScope.launch(Dispatchers.Main) {
try {
player.setSource(videoSource, audioSource, play = true, keepSubtitles = false, resume = resumePositionMs > 0)
if (subtitleSource != null) player.swapSubtitles(subtitleSource)
@@ -891,4 +887,397 @@ class ShortView : FrameLayout {
const val TAG = "VideoDetailView"
}
class CommentsModalBottomSheet : BottomSheetDialogFragment() {
var mainFragment: MainFragment? = null
private lateinit var containerContent: FrameLayout
private lateinit var containerContentMain: LinearLayout
private lateinit var containerContentReplies: RepliesOverlay
private lateinit var containerContentDescription: DescriptionOverlay
private lateinit var containerContentSupport: SupportOverlay
private lateinit var title: TextView
private lateinit var subTitle: TextView
private lateinit var channelName: TextView
private lateinit var channelMeta: TextView
private lateinit var creatorThumbnail: CreatorThumbnail
private lateinit var channelButton: LinearLayout
private lateinit var monetization: MonetizationView
private lateinit var platform: PlatformIndicator
private lateinit var textLikes: TextView
private lateinit var textDislikes: TextView
private lateinit var layoutRating: LinearLayout
private lateinit var imageDislikeIcon: ImageView
private lateinit var imageLikeIcon: ImageView
private lateinit var description: TextView
private lateinit var descriptionContainer: LinearLayout
private lateinit var descriptionViewMore: TextView
private lateinit var commentsList: CommentsList
private lateinit var addCommentView: AddCommentView
private var polycentricProfile: PolycentricProfile? = null
private lateinit var buttonPolycentric: Button
private lateinit var buttonPlatform: Button
private var tabIndex: Int? = null
private var contentOverlayView: View? = null
lateinit var video: IPlatformVideoDetails
private lateinit var behavior: BottomSheetBehavior<FrameLayout>
private val _taskLoadPolycentricProfile =
TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it)
}
override fun onCreateDialog(
savedInstanceState: Bundle?,
): Dialog {
val bottomSheetDialog =
BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme)
bottomSheetDialog.setContentView(R.layout.modal_comments)
behavior = bottomSheetDialog.behavior
// TODO figure out how to not need all of these non null assertions
containerContent = bottomSheetDialog.findViewById(R.id.content_container)!!
containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!!
containerContentReplies =
bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!!
containerContentDescription =
bottomSheetDialog.findViewById(R.id.videodetail_container_description)!!
containerContentSupport =
bottomSheetDialog.findViewById(R.id.videodetail_container_support)!!
title = bottomSheetDialog.findViewById(R.id.videodetail_title)!!
subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!!
channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!!
channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!!
creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!!
channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!!
monetization = bottomSheetDialog.findViewById(R.id.monetization)!!
platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!!
layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!!
textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!!
textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!!
imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!!
imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!!
description = bottomSheetDialog.findViewById(R.id.videodetail_description)!!
descriptionContainer =
bottomSheetDialog.findViewById(R.id.videodetail_description_container)!!
descriptionViewMore =
bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!!
addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!!
commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!!
buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!!
buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!!
commentsList.onAuthorClick.subscribe { c ->
if (c !is PolycentricPlatformComment) {
return@subscribe
}
val id = c.author.id.value
Logger.i(TAG, "onAuthorClick: $id")
if (id != null && id.startsWith("polycentric://")) {
val navUrl = "https://harbor.social/" + id.substring("polycentric://".length)
mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri()))
}
}
commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0
var metadata = ""
if (replyCount > 0) {
metadata += "$replyCount " + requireContext().getString(R.string.replies)
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c
containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, {
val newComment = parentComment.cloneWithUpdatedReplyCount(
(parentComment.replyCount ?: 0) + 1
)
commentsList.replaceComment(parentComment, newComment)
parentComment = newComment
})
} else {
containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) })
}
animateOpenOverlayView(containerContentReplies)
}
if (StatePolycentric.instance.enabled) {
buttonPolycentric.setOnClickListener {
setTabIndex(0)
StateMeta.instance.setLastCommentSection(0)
}
} else {
buttonPolycentric.visibility = GONE
}
buttonPlatform.setOnClickListener {
setTabIndex(1)
StateMeta.instance.setLastCommentSection(1)
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
addCommentView.setContext(video.url, ref)
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
setTabIndex(2, true)
} else {
when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
1 -> setTabIndex(1, true)
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
}
}
containerContentDescription.onClose.subscribe { animateCloseOverlayView() }
containerContentReplies.onClose.subscribe { animateCloseOverlayView() }
descriptionViewMore.setOnClickListener {
animateOpenOverlayView(containerContentDescription)
}
updateDescriptionUI(video.description.fixHtmlLinks())
val dp5 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics)
val dp2 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics)
//UI
title.text = video.name
channelName.text = video.author.name
if (video.author.subscribers != null) {
channelMeta.text = if ((video.author.subscribers
?: 0) > 0
) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""
(channelName.layoutParams as MarginLayoutParams).setMargins(
0, (dp5 * -1).toInt(), 0, 0
)
} else {
channelMeta.text = ""
(channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0)
}
video.author.let {
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl)
else monetization.setPlatformMembership(null, null)
}
val subTitleSegments: ArrayList<String> = ArrayList()
if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(R.string.watching_now) else requireContext().getString(R.string.views)}")
if (video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0
val ago = video.datetime?.toHumanNowDiffString(true)
if (diff >= 0) subTitleSegments.add("$ago ago")
else subTitleSegments.add("available in $ago")
}
platform.setPlatformFromClientID(video.id.pluginId)
subTitle.text = subTitleSegments.joinToString("")
creatorThumbnail.setThumbnail(video.author.thumbnail, false)
setPolycentricProfile(null, animate = false)
_taskLoadPolycentricProfile.run(video.author.id)
when (video.rating) {
is RatingLikeDislikes -> {
val r = video.rating as RatingLikeDislikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = VISIBLE
textDislikes.visibility = VISIBLE
textDislikes.text = r.dislikes.toHumanNumber()
}
is RatingLikes -> {
val r = video.rating as RatingLikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = GONE
textDislikes.visibility = GONE
}
else -> {
layoutRating.visibility = GONE
}
}
monetization.onSupportTap.subscribe {
containerContentSupport.setPolycentricProfile(polycentricProfile)
animateOpenOverlayView(containerContentSupport)
}
monetization.onStoreTap.subscribe {
polycentricProfile?.systemState?.store?.let {
try {
val uri = it.toUri()
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
requireContext().startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e)
}
}
}
monetization.onUrlTap.subscribe {
mainFragment!!.navigate<BrowserFragment>(it)
}
addCommentView.onCommentAdded.subscribe {
commentsList.addComment(it)
}
channelButton.setOnClickListener {
mainFragment!!.navigate<ChannelFragment>(video.author)
}
return bottomSheetDialog
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
animateCloseOverlayView()
}
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
polycentricProfile = profile
val dp35 = 35.dp(requireContext().resources)
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
if (avatar != null) {
creatorThumbnail.setThumbnail(avatar, animate)
} else {
creatorThumbnail.setThumbnail(video.author.thumbnail, animate)
creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto())
}
val username = profile?.systemState?.username
if (username != null) {
channelName.text = username
}
monetization.setPolycentricProfile(profile)
}
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
val changed = tabIndex != index || forceReload
if (!changed) {
return
}
tabIndex = index
buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null))
buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null))
when (index) {
null -> {
addCommentView.visibility = GONE
commentsList.clear()
}
0 -> {
addCommentView.visibility = VISIBLE
fetchPolycentricComments()
}
1 -> {
addCommentView.visibility = GONE
fetchComments()
}
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
video.let {
commentsList.load(true) { StatePlatform.instance.getComments(it) }
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val video = video
val idValue = video.id.value
if (video.url.isEmpty()) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
commentsList.clear()
return
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }
}
private fun updateDescriptionUI(text: Spanned) {
containerContentDescription.load(text)
description.text = text
if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE
else descriptionContainer.visibility = GONE
}
private fun animateOpenOverlayView(view: View) {
if (contentOverlayView != null) {
Logger.e(TAG, "Content overlay already open")
return
}
behavior.isDraggable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
val animHeight = containerContentMain.height
view.translationY = animHeight.toFloat()
view.visibility = VISIBLE
view.animate().setDuration(300).translationY(0f).withEndAction {
contentOverlayView = view
}.start()
}
private fun animateCloseOverlayView() {
val curView = contentOverlayView
if (curView == null) {
Logger.e(TAG, "No content overlay open")
return
}
behavior.isDraggable = true
val animHeight = contentOverlayView!!.height
curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction {
curView.visibility = GONE
contentOverlayView = null
}.start()
}
companion object {
const val TAG = "ModalBottomSheet"
}
}
}
@@ -7,12 +7,10 @@ import android.view.LayoutInflater
import android.view.SoundEffectConstants
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
@@ -27,9 +25,6 @@ import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.buttons.BigButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.system.measureTimeMillis
@UnstableApi
class ShortsFragment : MainFragment() {
@@ -40,7 +35,6 @@ class ShortsFragment : MainFragment() {
private var loadPagerTask: TaskHandler<ShortsFragment, IPager<IPlatformVideo>>? = null
private var nextPageTask: TaskHandler<ShortsFragment, List<IPlatformVideo>>? = null
//TODO: Reduce number of pagers (1, or at most 2)
private var mainShortsPager: IPager<IPlatformVideo>? = null
private val mainShorts: MutableList<IPlatformVideo> = mutableListOf()
@@ -64,7 +58,6 @@ class ShortsFragment : MainFragment() {
private var customViewAdapter: CustomViewAdapter? = null
// we just completely reset the data structure so we want to tell the adapter that
//TODO: Move most of this logic to ShortsView
@SuppressLint("NotifyDataSetChanged")
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
(activity as MainActivity?)?.getFragment<VideoDetailFragment>()?.closeVideoDetails()
@@ -125,6 +118,7 @@ class ShortsFragment : MainFragment() {
overlayQualityContainer = view.findViewById(R.id.shorts_quality_overview)
sourcesButton.onClick.subscribe {
sourcesButton.playSoundEffect(SoundEffectConstants.CLICK)
navigate<SourcesFragment>()
}
@@ -151,7 +145,7 @@ class ShortsFragment : MainFragment() {
this.customViewAdapter = customViewAdapter
if (loadPagerTask == null) {// && currentShorts.isEmpty()) {
if (loadPagerTask == null && currentShorts.isEmpty()) {
loadPager()
loadPagerTask!!.success {
@@ -213,29 +207,28 @@ class ShortsFragment : MainFragment() {
}
private fun nextPage() {
Logger.i(TAG, "ShortsFragment nextPage");
lifecycleScope.launch(Dispatchers.IO) {
try {
val time = measureTimeMillis {
currentShortsPager!!.nextPage();
}
val newVideos = currentShortsPager!!.getResults();
nextPageTask?.cancel()
val nextPageTask =
TaskHandler<ShortsFragment, List<IPlatformVideo>>(StateApp.instance.scopeGetter, {
currentShortsPager!!.nextPage()
return@TaskHandler currentShortsPager!!.getResults()
}).success { newVideos ->
val prevCount = customViewAdapter!!.itemCount
Logger.i(TAG, "Shorts nextPage took ${time}ms, ${prevCount}-${prevCount + newVideos.size}, hasMore: ${currentShortsPager?.hasMorePages()}");
currentShorts.addAll(newVideos)
if (isChannelShortsMode) {
channelShorts.addAll(newVideos)
} else {
mainShorts.addAll(newVideos)
}
lifecycleScope.launch(Dispatchers.Main) {
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
}
customViewAdapter!!.notifyItemRangeInserted(prevCount, newVideos.size)
nextPageTask = null
} catch (ex: Throwable) {
Logger.e(TAG, "Shorts Failed to call nextPage", ex);
}
}
nextPageTask.run(this)
this.nextPageTask = nextPageTask
}
// we just completely reset the data structure so we want to tell the adapter that
@@ -243,16 +236,12 @@ class ShortsFragment : MainFragment() {
private fun loadPager() {
loadPagerTask?.cancel()
Logger.i(TAG, "Shorts loadPage");
var loadPageStart = System.currentTimeMillis();
val loadPagerTask =
TaskHandler<ShortsFragment, IPager<IPlatformVideo>>(StateApp.instance.scopeGetter, {
val pager = StatePlatform.instance.getShorts();
val pager = StatePlatform.instance.getShorts()
return@TaskHandler pager
}).success { pager ->
val timeLoadPage = System.currentTimeMillis() - loadPageStart;
Logger.i(TAG, "Shorts loadPage took ${timeLoadPage}ms");
mainShorts.clear()
mainShorts.addAll(pager.getResults())
mainShortsPager = pager
@@ -270,7 +259,7 @@ class ShortsFragment : MainFragment() {
loadPagerTask = null
}.exception<Throwable> { err ->
val message = "Unable to load shorts $err"
Logger.w(TAG, message, err)
Logger.i(TAG, message)
if (context != null) {
UIDialogs.showDialog(
requireContext(), R.drawable.ic_sources, message, null, null, 0, UIDialogs.Action(
@@ -310,12 +299,6 @@ class ShortsFragment : MainFragment() {
customViewAdapter?.previousShownView?.stop()
}
override fun onDestroyMainView() {
super.onDestroyMainView()
Logger.i(TAG, "Keep screen on cleared because onDestroyMainView fragment")
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
companion object {
private const val TAG = "ShortsFragment"
@@ -346,7 +329,6 @@ class ShortsFragment : MainFragment() {
@OptIn(UnstableApi::class)
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
Logger.i(TAG, "Shorts change (position: ${position}): ${videos[position].name} (${videos[position].id.value})")
holder.shortView.changeVideo(videos[position], isChannelShortsMode())
if (position == itemCount - 1) {
@@ -25,7 +25,6 @@ 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
@@ -153,50 +152,11 @@ class SourceDetailFragment : MainFragment() {
if(field is View)
field.isVisible = false;
}
if(!source.capabilities.hasGetUserHistory || !source.isLoggedIn) {
if(!source.capabilities.hasGetUserHistory) {
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;
@@ -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?.isAudioOnlyUserAction}");
Logger.i(TAG, "onUserLeaveHint preventPictureInPicture=${viewDetail?.preventPictureInPicture} isCasting=${StateCasting.instance.isCasting} isBackgroundPictureInPicture=${Settings.instance.playback.isBackgroundPictureInPicture()} allowBackground=${viewDetail?.allowBackground}");
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.isAudioOnlyUserAction) {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.S && viewDetail.preventPictureInPicture == false && !StateCasting.instance.isCasting && Settings.instance.playback.isBackgroundPictureInPicture() && !viewDetail.allowBackground) {
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?.isAudioOnlyUserAction == true) {
if (_viewDetail?.allowBackground == true) {
shouldStop = false;
} else if (Settings.instance.playback.isBackgroundPictureInPicture() && !_leavingPiP) {
shouldStop = false;
@@ -10,6 +10,7 @@ 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
@@ -50,6 +51,7 @@ 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
@@ -80,6 +82,7 @@ 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
@@ -244,7 +247,6 @@ class VideoDetailView : ConstraintLayout {
private val _buttonSubscribe: SubscribeButton;
private val _buttonPins: RoundButtonGroup;
private var _loaderGameVisible = false
//private val _buttonMore: RoundButton;
var preventPictureInPicture: Boolean = false
@@ -262,6 +264,7 @@ class VideoDetailView : ConstraintLayout {
private val _textSkip: TextView;
private val _textResume: TextView;
private val _layoutResume: LinearLayout;
private var _jobHideResume: Job? = null;
private val _layoutPlayerContainer: TouchInterceptFrameLayout;
private val _layoutChangeBottomSection: LinearLayout;
@@ -323,7 +326,7 @@ class VideoDetailView : ConstraintLayout {
val onEnterPictureInPicture = Event0();
val onVideoChanged = Event2<Int, Int>()
var isAudioOnlyUserAction: Boolean = false
var allowBackground: Boolean = false
private set(value) {
if (field != value) {
field = value
@@ -335,8 +338,8 @@ class VideoDetailView : ConstraintLayout {
get() = !preventPictureInPicture &&
!StateCasting.instance.isCasting &&
Settings.instance.playback.isBackgroundPictureInPicture() &&
!isAudioOnlyUserAction &&
(isPlaying || _loaderGameVisible)
!allowBackground &&
isPlaying
val onShouldEnterPictureInPictureChanged = Event0();
@@ -357,7 +360,6 @@ class VideoDetailView : ConstraintLayout {
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
Pair(0, 10) //around live, try every 10 seconds
);
private var _subtitleLanguage: String? = null
@androidx.annotation.OptIn(UnstableApi::class)
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
@@ -549,16 +551,6 @@ class VideoDetailView : ConstraintLayout {
_buttonMore = buttonMore;
updateMoreButtons();
val handleLoaderGameVisibilityChanged = { b: Boolean ->
_loaderGameVisible = b
fragment.lifecycleScope.launch(Dispatchers.Main) {
onShouldEnterPictureInPictureChanged.emit()
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
_player.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_cast.loaderGameVisibilityChanged.subscribe(handleLoaderGameVisibilityChanged)
_channelButton.setOnClickListener {
if (video is TutorialFragment.TutorialVideo) {
return@setOnClickListener
@@ -587,8 +579,9 @@ class VideoDetailView : ConstraintLayout {
if(chapter?.type == ChapterType.SKIPPABLE) {
_layoutSkip.visibility = VISIBLE;
} else if(chapter?.type == ChapterType.SKIP || chapter?.type == ChapterType.SKIPONCE) {
if (StateCasting.instance.activeDevice != null) {
StateCasting.instance.videoSeekTo(chapter.timeEnd)
val ad = StateCasting.instance.activeDevice
if (ad != null) {
ad.seekVideo(chapter.timeEnd)
} else {
_player.seekTo((chapter.timeEnd * 1000).toLong());
}
@@ -771,7 +764,7 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onBackgroundReceived.subscribe(this) {
Logger.i(TAG, "MediaControlReceiver.onBackgroundReceived")
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
allowBackground = true;
StateApp.instance.contextOrNull?.let {
try {
if (it is MainActivity) {
@@ -883,6 +876,11 @@ class VideoDetailView : ConstraintLayout {
_layoutResume.setOnClickListener {
handleSeek(_historicalPosition * 1000);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
_layoutResume.visibility = View.GONE;
};
@@ -891,7 +889,7 @@ class VideoDetailView : ConstraintLayout {
if (ad != null) {
val currentChapter = _cast.getCurrentChapter((ad.time * 1000).toLong());
if(currentChapter?.type == ChapterType.SKIPPABLE) {
StateCasting.instance.videoSeekTo(currentChapter.timeEnd);
ad.seekVideo(currentChapter.timeEnd);
}
} else {
val currentChapter = _player.getCurrentChapter(_player.position);
@@ -1010,26 +1008,15 @@ 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 (isAudioOnlyUserAction) context.getString(R.string.background_revert) else context.getString(R.string.background), TAG_BACKGROUND) {
if (!isAudioOnlyUserAction) {
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) {
_player.switchToAudioMode(video);
isAudioOnlyUserAction = true;
allowBackground = true;
it.text.text = resources.getString(R.string.background_revert);
} else {
_player.switchToVideoMode();
isAudioOnlyUserAction = false;
allowBackground = false;
it.text.text = resources.getString(R.string.background);
}
_slideUpOverlay?.hide();
@@ -1145,23 +1132,19 @@ 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 && !wasLoginCall)
if(_searchVideo != null)
setVideoOverview(_searchVideo!!, true, t);
else if(_url != null && !wasLoginCall)
else if(_url != null)
setVideo(_url!!, t, _playWhenReady);
}
else if(_didStop) {
@@ -1173,12 +1156,11 @@ class VideoDetailView : ConstraintLayout {
if(_player.isAudioMode) {
//Requested behavior to leave it in audio mode. leaving it commented if it causes issues, revert?
if(!isAudioOnlyUserAction) {
if(!allowBackground) {
_player.switchToVideoMode();
isAudioOnlyUserAction = false;
allowBackground = false;
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.background);
}
else {
} else {
_buttonPins.getButtonByTag(TAG_BACKGROUND)?.text?.text = resources.getString(R.string.video);
}
}
@@ -1196,7 +1178,7 @@ class VideoDetailView : ConstraintLayout {
if(StateCasting.instance.isCasting)
return;
if(isAudioOnlyUserAction)
if(allowBackground)
StatePlayer.instance.startOrUpdateMediaSession(context, video);
else {
when (Settings.instance.playback.backgroundPlay) {
@@ -1204,6 +1186,7 @@ class VideoDetailView : ConstraintLayout {
1 -> {
if(!(video?.isLive ?: false)) {
_player.switchToAudioMode(video);
allowBackground = true;
}
StatePlayer.instance.startOrUpdateMediaSession(context, video);
}
@@ -1261,6 +1244,10 @@ class VideoDetailView : ConstraintLayout {
MediaControlReceiver.onCloseReceived.remove(this);
MediaControlReceiver.onBackgroundReceived.remove(this);
MediaControlReceiver.onSeekToReceived.remove(this);
val job = _jobHideResume;
_jobHideResume = null;
job?.cancel();
}
//Video Setters
@@ -1782,7 +1769,26 @@ class VideoDetailView : ConstraintLayout {
TAG,
"Historical position: $_historicalPosition, last position: $lastPositionMilliseconds"
);
updateResumeVisibilityFor(lastPositionMilliseconds)
if (_historicalPosition > 60 && video.duration - _historicalPosition > 5 && Math.abs(
_historicalPosition - lastPositionMilliseconds / 1000
) > 5.0
) {
_layoutResume.visibility = View.VISIBLE;
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}";
_jobHideResume = fragment.lifecycleScope.launch(Dispatchers.Main) {
try {
delay(8000);
_layoutResume.visibility = View.GONE;
_textResume.text = "";
} catch (e: Throwable) {
Logger.e(TAG, "Failed to set resume changes.", e);
}
}
} else {
_layoutResume.visibility = View.GONE;
_textResume.text = "";
}
}
}
}
@@ -1828,35 +1834,6 @@ class VideoDetailView : ConstraintLayout {
_taskLoadRecommendations.run(videoDetail.url)
}
}
private fun shouldShowResume(positionMs: Long): Boolean {
if (_loaderGameVisible) return false
val v = video ?: return false
val resumeS = _historicalPosition
val durS = v.duration
if (_overlay_loading.visibility == View.VISIBLE) return false
if (resumeS <= 60) return false
if (durS - resumeS <= 5) return false
val posMs = positionMs
val resumeMs = resumeS * 1000
val durMs = durS * 1000L
val inFirstFewSeconds = posMs < 8_000
val notYetReachedResume = (resumeMs - posMs) > 5_000
return inFirstFewSeconds && notYetReachedResume && durMs > 0
}
private fun updateResumeVisibilityFor(positionMs: Long) {
val visible = shouldShowResume(positionMs)
if (visible) {
_layoutResume.visibility = View.VISIBLE
_textResume.text = "Resume at ${_historicalPosition.toHumanTime(false)}"
} else {
_layoutResume.visibility = View.GONE
_textResume.text = ""
}
}
fun loadVODChat(video: IPlatformVideoDetails) {
_liveChat?.stop();
_container_content_liveChat.cancel();
@@ -1976,7 +1953,7 @@ class VideoDetailView : ConstraintLayout {
try {
val videoSource = _lastVideoSource ?: _player.getPreferredVideoSource(video, Settings.instance.playback.getCurrentPreferredQualityPixelCount());
val audioSource = _lastAudioSource ?: _player.getPreferredAudioSource(video, Settings.instance.playback.getPrimaryLanguage(context));
val subtitleSource = _lastSubtitleSource ?: (if (Settings.instance.playback.stickySubtitles) _player.getPreferredSubtitleSource(video, _subtitleLanguage) else null) ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
val subtitleSource = _lastSubtitleSource ?: (if(video is VideoLocal) video.subtitlesSources.firstOrNull() else null);
Logger.i(TAG, "loadCurrentVideo(videoSource=$videoSource, audioSource=$audioSource, subtitleSource=$subtitleSource, resumePositionMs=$resumePositionMs)")
if(videoSource == null && audioSource == null) {
@@ -1997,10 +1974,10 @@ class VideoDetailView : ConstraintLayout {
if (isLimitedVersion && _player.isAudioMode) {
_player.switchToVideoMode()
isAudioOnlyUserAction = false;
allowBackground = false;
} else {
val thumbnail = video.thumbnails.getHQThumbnail();
if ((videoSource == null) && !thumbnail.isNullOrBlank()) // || _player.isAudioMode
if ((videoSource == null || _player.isAudioMode) && !thumbnail.isNullOrBlank())
Glide.with(context).asBitmap().load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@@ -2379,11 +2356,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();
@@ -2404,7 +2381,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
}
@@ -2528,7 +2505,6 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.resumeVideo()) {
_player.play();
}
onShouldEnterPictureInPictureChanged.emit()
//TODO: This was needed because handleLowerVolume was done.
//_player.setVolume(1.0f);
@@ -2545,7 +2521,6 @@ class VideoDetailView : ConstraintLayout {
if (!StateCasting.instance.pauseVideo()) {
_player.pause();
}
onShouldEnterPictureInPictureChanged.emit()
}
private fun handleSeek(ms: Long) {
Logger.i(TAG, "handleSeek(ms=$ms)")
@@ -2660,7 +2635,6 @@ class VideoDetailView : ConstraintLayout {
}
}
_lastSubtitleSource = toSet;
_subtitleLanguage = toSet?.language
}
private fun handleUnavailableVideo(msg: String? = null) {
@@ -2818,8 +2792,6 @@ class VideoDetailView : ConstraintLayout {
_overlay_loading.visibility = View.GONE;
(_overlay_loading_spinner.drawable as Animatable?)?.stop()
}
updateResumeVisibilityFor(lastPositionMilliseconds)
}
//UI Actions
@@ -3053,9 +3025,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, 2));
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));
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, 1));
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));
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));
@@ -3110,8 +3082,6 @@ class VideoDetailView : ConstraintLayout {
handleSeek(55000);
}
}
updateResumeVisibilityFor(positionMilliseconds)
}
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
@@ -3297,13 +3267,8 @@ class VideoDetailView : ConstraintLayout {
val id = e.config.let { if(it is SourcePluginConfig) it.id else null };
val didLogin = if(id == null)
false
else {
isLoginStop = true;
StatePlugins.instance.loginPlugin(context, id) {
fragment.lifecycleScope.launch(Dispatchers.Main) {
fetchVideo();
}
}
else StatePlugins.instance.loginPlugin(context, id) {
fetchVideo();
}
if(!didLogin)
UIDialogs.showDialogOk(context, R.drawable.ic_error_pred, "Failed to login");
@@ -3481,7 +3446,6 @@ 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";
@@ -1,454 +0,0 @@
package com.futo.platformplayer.fragment.mainactivity.special
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.text.Spanned
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Button
import android.widget.FrameLayout
import android.widget.FrameLayout.GONE
import android.widget.FrameLayout.VISIBLE
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.net.toUri
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlLinks
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
import com.futo.platformplayer.fragment.mainactivity.main.MainFragment
import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.toHumanNowDiffString
import com.futo.platformplayer.toHumanNumber
import com.futo.platformplayer.views.MonetizationView
import com.futo.platformplayer.views.comments.AddCommentView
import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.segments.CommentsList
import com.futo.polycentric.core.ApiMethods
import com.futo.polycentric.core.Models
import com.futo.polycentric.core.PolycentricProfile
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class CommentsModalBottomSheet : BottomSheetDialogFragment() {
var mainFragment: MainFragment? = null
private lateinit var containerContent: FrameLayout
private lateinit var containerContentMain: LinearLayout
private lateinit var containerContentReplies: RepliesOverlay
private lateinit var containerContentDescription: DescriptionOverlay
private lateinit var containerContentSupport: SupportOverlay
private lateinit var title: TextView
private lateinit var subTitle: TextView
private lateinit var channelName: TextView
private lateinit var channelMeta: TextView
private lateinit var creatorThumbnail: CreatorThumbnail
private lateinit var channelButton: LinearLayout
private lateinit var monetization: MonetizationView
private lateinit var platform: PlatformIndicator
private lateinit var textLikes: TextView
private lateinit var textDislikes: TextView
private lateinit var layoutRating: LinearLayout
private lateinit var imageDislikeIcon: ImageView
private lateinit var imageLikeIcon: ImageView
private lateinit var description: TextView
private lateinit var descriptionContainer: LinearLayout
private lateinit var descriptionViewMore: TextView
private lateinit var commentsList: CommentsList
private lateinit var addCommentView: AddCommentView
private var polycentricProfile: PolycentricProfile? = null
private lateinit var buttonPolycentric: Button
private lateinit var buttonPlatform: Button
private var tabIndex: Int? = null
private var contentOverlayView: View? = null
lateinit var video: IPlatformVideoDetails
private lateinit var behavior: BottomSheetBehavior<FrameLayout>
private val _taskLoadPolycentricProfile =
TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, { ApiMethods.getPolycentricProfileByClaim(
ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!) }).success { setPolycentricProfile(it, animate = true) }
.exception<Throwable> {
Logger.w(TAG, "Failed to load claims.", it)
}
override fun onCreateDialog(
savedInstanceState: Bundle?,
): Dialog {
val bottomSheetDialog =
BottomSheetDialog(requireContext(), R.style.Custom_BottomSheetDialog_Theme)
bottomSheetDialog.setContentView(R.layout.modal_comments)
behavior = bottomSheetDialog.behavior
// TODO figure out how to not need all of these non null assertions
containerContent = bottomSheetDialog.findViewById(R.id.content_container)!!
containerContentMain = bottomSheetDialog.findViewById(R.id.videodetail_container_main)!!
containerContentReplies =
bottomSheetDialog.findViewById(R.id.videodetail_container_replies)!!
containerContentDescription =
bottomSheetDialog.findViewById(R.id.videodetail_container_description)!!
containerContentSupport =
bottomSheetDialog.findViewById(R.id.videodetail_container_support)!!
title = bottomSheetDialog.findViewById(R.id.videodetail_title)!!
subTitle = bottomSheetDialog.findViewById(R.id.videodetail_meta)!!
channelName = bottomSheetDialog.findViewById(R.id.videodetail_channel_name)!!
channelMeta = bottomSheetDialog.findViewById(R.id.videodetail_channel_meta)!!
creatorThumbnail = bottomSheetDialog.findViewById(R.id.creator_thumbnail)!!
channelButton = bottomSheetDialog.findViewById(R.id.videodetail_channel_button)!!
monetization = bottomSheetDialog.findViewById(R.id.monetization)!!
platform = bottomSheetDialog.findViewById(R.id.videodetail_platform)!!
layoutRating = bottomSheetDialog.findViewById(R.id.layout_rating)!!
textDislikes = bottomSheetDialog.findViewById(R.id.text_dislikes)!!
textLikes = bottomSheetDialog.findViewById(R.id.text_likes)!!
imageLikeIcon = bottomSheetDialog.findViewById(R.id.image_like_icon)!!
imageDislikeIcon = bottomSheetDialog.findViewById(R.id.image_dislike_icon)!!
description = bottomSheetDialog.findViewById(R.id.videodetail_description)!!
descriptionContainer =
bottomSheetDialog.findViewById(R.id.videodetail_description_container)!!
descriptionViewMore =
bottomSheetDialog.findViewById(R.id.videodetail_description_view_more)!!
addCommentView = bottomSheetDialog.findViewById(R.id.add_comment_view)!!
commentsList = bottomSheetDialog.findViewById(R.id.comments_list)!!
buttonPolycentric = bottomSheetDialog.findViewById(R.id.button_polycentric)!!
buttonPlatform = bottomSheetDialog.findViewById(R.id.button_platform)!!
commentsList.onAuthorClick.subscribe { c ->
if (c !is PolycentricPlatformComment) {
return@subscribe
}
val id = c.author.id.value
Logger.i(TAG, "onAuthorClick: $id")
if (id != null && id.startsWith("polycentric://")) {
val navUrl = "https://harbor.social/" + id.substring("polycentric://".length)
mainFragment!!.startActivity(Intent(Intent.ACTION_VIEW, navUrl.toUri()))
}
}
commentsList.onRepliesClick.subscribe { c ->
val replyCount = c.replyCount ?: 0
var metadata = ""
if (replyCount > 0) {
metadata += "$replyCount " + requireContext().getString(R.string.replies)
}
if (c is PolycentricPlatformComment) {
var parentComment: PolycentricPlatformComment = c
containerContentReplies.load(tabIndex!! != 0, metadata, c.contextUrl, c.reference, c, { StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) }, {
val newComment = parentComment.cloneWithUpdatedReplyCount(
(parentComment.replyCount ?: 0) + 1
)
commentsList.replaceComment(parentComment, newComment)
parentComment = newComment
})
} else {
containerContentReplies.load(tabIndex!! != 0, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) })
}
animateOpenOverlayView(containerContentReplies)
}
if (StatePolycentric.instance.enabled) {
buttonPolycentric.setOnClickListener {
setTabIndex(0)
StateMeta.instance.setLastCommentSection(0)
}
} else {
buttonPolycentric.visibility = GONE
}
buttonPlatform.setOnClickListener {
setTabIndex(1)
StateMeta.instance.setLastCommentSection(1)
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
addCommentView.setContext(video.url, ref)
if (Settings.instance.comments.recommendationsDefault && !Settings.instance.comments.hideRecommendations) {
setTabIndex(2, true)
} else {
when (Settings.instance.comments.defaultCommentSection) {
0 -> if (Settings.instance.other.polycentricEnabled) setTabIndex(0, true) else setTabIndex(1, true)
1 -> setTabIndex(1, true)
2 -> setTabIndex(StateMeta.instance.getLastCommentSection(), true)
}
}
containerContentDescription.onClose.subscribe { animateCloseOverlayView() }
containerContentReplies.onClose.subscribe { animateCloseOverlayView() }
descriptionViewMore.setOnClickListener {
animateOpenOverlayView(containerContentDescription)
}
updateDescriptionUI(video.description.fixHtmlLinks())
val dp5 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics)
val dp2 =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics)
//UI
title.text = video.name
channelName.text = video.author.name
if (video.author.subscribers != null) {
channelMeta.text = if ((video.author.subscribers
?: 0) > 0
) video.author.subscribers!!.toHumanNumber() + " " + requireContext().getString(R.string.subscribers) else ""
(channelName.layoutParams as MarginLayoutParams).setMargins(
0, (dp5 * -1).toInt(), 0, 0
)
} else {
channelMeta.text = ""
(channelName.layoutParams as MarginLayoutParams).setMargins(0, (dp2).toInt(), 0, 0)
}
video.author.let {
if (it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty()) monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl)
else monetization.setPlatformMembership(null, null)
}
val subTitleSegments: ArrayList<String> = ArrayList()
if (video.viewCount > 0) subTitleSegments.add("${video.viewCount.toHumanNumber()} ${if (video.isLive) requireContext().getString(
R.string.watching_now) else requireContext().getString(R.string.views)}")
if (video.datetime != null) {
val diff = video.datetime?.getNowDiffSeconds() ?: 0
val ago = video.datetime?.toHumanNowDiffString(true)
if (diff >= 0) subTitleSegments.add("$ago ago")
else subTitleSegments.add("available in $ago")
}
platform.setPlatformFromClientID(video.id.pluginId)
subTitle.text = subTitleSegments.joinToString("")
creatorThumbnail.setThumbnail(video.author.thumbnail, false)
setPolycentricProfile(null, animate = false)
_taskLoadPolycentricProfile.run(video.author.id)
when (video.rating) {
is RatingLikeDislikes -> {
val r = video.rating as RatingLikeDislikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = VISIBLE
textDislikes.visibility = VISIBLE
textDislikes.text = r.dislikes.toHumanNumber()
}
is RatingLikes -> {
val r = video.rating as RatingLikes
layoutRating.visibility = VISIBLE
textLikes.visibility = VISIBLE
imageLikeIcon.visibility = VISIBLE
textLikes.text = r.likes.toHumanNumber()
imageDislikeIcon.visibility = GONE
textDislikes.visibility = GONE
}
else -> {
layoutRating.visibility = GONE
}
}
monetization.onSupportTap.subscribe {
containerContentSupport.setPolycentricProfile(polycentricProfile)
animateOpenOverlayView(containerContentSupport)
}
monetization.onStoreTap.subscribe {
polycentricProfile?.systemState?.store?.let {
try {
val uri = it.toUri()
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
requireContext().startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e)
}
}
}
monetization.onUrlTap.subscribe {
mainFragment!!.navigate<BrowserFragment>(it)
}
addCommentView.onCommentAdded.subscribe {
commentsList.addComment(it)
}
channelButton.setOnClickListener {
mainFragment!!.navigate<ChannelFragment>(video.author)
}
return bottomSheetDialog
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
animateCloseOverlayView()
}
private fun setPolycentricProfile(profile: PolycentricProfile?, animate: Boolean) {
polycentricProfile = profile
val dp35 = 35.dp(requireContext().resources)
val avatar = profile?.systemState?.avatar?.selectBestImage(dp35 * dp35)
?.let { it.toURLInfoSystemLinkUrl(profile.system.toProto(), it.process, profile.systemState.servers.toList()) }
if (avatar != null) {
creatorThumbnail.setThumbnail(avatar, animate)
} else {
creatorThumbnail.setThumbnail(video.author.thumbnail, animate)
creatorThumbnail.setHarborAvailable(profile != null, animate, profile?.system?.toProto())
}
val username = profile?.systemState?.username
if (username != null) {
channelName.text = username
}
monetization.setPolycentricProfile(profile)
}
private fun setTabIndex(index: Int?, forceReload: Boolean = false) {
Logger.i(TAG, "setTabIndex (index: ${index}, forceReload: ${forceReload})")
val changed = tabIndex != index || forceReload
if (!changed) {
return
}
tabIndex = index
buttonPlatform.setTextColor(resources.getColor(if (index == 1) R.color.white else R.color.gray_ac, null))
buttonPolycentric.setTextColor(resources.getColor(if (index == 0) R.color.white else R.color.gray_ac, null))
when (index) {
null -> {
addCommentView.visibility = GONE
commentsList.clear()
}
0 -> {
addCommentView.visibility = VISIBLE
fetchPolycentricComments()
}
1 -> {
addCommentView.visibility = GONE
fetchComments()
}
}
}
private fun fetchComments() {
Logger.i(TAG, "fetchComments")
video.let {
commentsList.load(true) { StatePlatform.instance.getComments(it) }
}
}
private fun fetchPolycentricComments() {
Logger.i(TAG, "fetchPolycentricComments")
val video = video
val idValue = video.id.value
if (video.url.isEmpty()) {
Logger.w(TAG, "Failed to fetch polycentric comments because url was null")
commentsList.clear()
return
}
val ref = Models.referenceFromBuffer(video.url.toByteArray())
val extraBytesRef = idValue?.let { if (it.isNotEmpty()) it.toByteArray() else null }
commentsList.load(false) { StatePolycentric.instance.getCommentPager(video.url, ref, listOfNotNull(extraBytesRef)); }
}
private fun updateDescriptionUI(text: Spanned) {
containerContentDescription.load(text)
description.text = text
if (description.text.isNotEmpty()) descriptionContainer.visibility = VISIBLE
else descriptionContainer.visibility = GONE
}
private fun animateOpenOverlayView(view: View) {
if (contentOverlayView != null) {
Logger.e(TAG, "Content overlay already open")
return
}
behavior.isDraggable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
val animHeight = containerContentMain.height
view.translationY = animHeight.toFloat()
view.visibility = VISIBLE
view.animate().setDuration(300).translationY(0f).withEndAction {
contentOverlayView = view
}.start()
}
private fun animateCloseOverlayView() {
val curView = contentOverlayView
if (curView == null) {
Logger.e(TAG, "No content overlay open")
return
}
behavior.isDraggable = true
val animHeight = contentOverlayView!!.height
curView.animate().setDuration(300).translationY(animHeight.toFloat()).withEndAction {
curView.visibility = GONE
contentOverlayView = null
}.start()
}
companion object {
const val TAG = "ModalBottomSheet"
}
}
@@ -19,8 +19,6 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSour
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IWidevineSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
@@ -136,62 +134,6 @@ class VideoHelper {
return bestSource;
}
fun selectBestSubtitleSource(sources: Iterable<ISubtitleSource>, preferredLanguage: String?): ISubtitleSource? {
if (preferredLanguage.isNullOrBlank()) return null
val prefTag = normalizeTag(preferredLanguage)
val prefPrimary = primarySubtag(prefTag) ?: return null
var best: ISubtitleSource? = null
var bestKey: Quad<Int, Int, String, String>? = null
for (src in sources) {
val raw = src.language ?: continue
val tag = normalizeTag(raw)
val primary = primarySubtag(tag) ?: continue
val score = when {
tag.equals(prefTag, ignoreCase = true) -> 0
primary.equals(prefPrimary, ignoreCase = true) && findRegion(tag) == null -> 1
primary.equals(prefPrimary, ignoreCase = true) -> 2
else -> 3
}
if (score >= 3) continue
val key = Quad(score, src.name.length, tag.lowercase(), src.name)
if (bestKey == null || key < bestKey!!) {
bestKey = key
best = src
}
}
return best
}
private fun normalizeTag(tag: String): String = tag.trim().replace('_', '-')
private fun primarySubtag(tag: String): String? = tag.split('-').firstOrNull { it.isNotBlank() }?.lowercase()
private fun findRegion(tag: String): String? {
val parts = tag.split('-').drop(1) // skip primary language
for (p in parts) {
val isAlpha2 = p.length == 2 && p[0].isLetter() && p[1].isLetter()
val isNumeric3 = p.length == 3 && p.all { it.isDigit() }
if (isAlpha2 || isNumeric3) return p.uppercase()
}
return null
}
private data class Quad<A : Comparable<A>, B : Comparable<B>, C : Comparable<C>, D : Comparable<D>>(
val a: A, val b: B, val c: C, val d: D
) : Comparable<Quad<A, B, C, D>> {
override fun compareTo(other: Quad<A, B, C, D>): Int =
when {
a != other.a -> a.compareTo(other.a)
b != other.b -> b.compareTo(other.b)
c != other.c -> c.compareTo(other.c)
else -> d.compareTo(other.d)
}
}
@OptIn(UnstableApi::class)
fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : Pair<MediaSource, String> {
val urlToUse = videoSource.getVideoUrl();
@@ -3,9 +3,16 @@ 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
)
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;
}
}
@@ -1,80 +0,0 @@
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,9 +66,10 @@ class DownloadService : Service() {
return START_NOT_STICKY;
if(!FragmentedStorage.isInitialized) {
Logger.i(TAG, "Attempted to start DownloadService without initialized files")
closeDownloadSession()
return START_NOT_STICKY
Logger.i(TAG, "Attempted to start DownloadService without initialized files");
stopSelf()
closeDownloadSession();
return START_NOT_STICKY;
}
_started = true;
}
@@ -106,19 +107,12 @@ class DownloadService : Service() {
return START_STICKY;
}
fun setupNotificationRequirements() {
_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!!)
_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!!);
}
override fun onCreate() {
@@ -299,28 +293,21 @@ class DownloadService : Service() {
val notif = builder.build();
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
if (_isForeground) {
_notificationManager?.notify(DOWNLOAD_NOTIF_ID, notif)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(DOWNLOAD_NOTIF_ID, notif, FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
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
startForeground(DOWNLOAD_NOTIF_ID, notif);
}
}
fun closeDownloadSession() {
Logger.i(TAG, "closeDownloadSession")
if (_isForeground) {
stopForeground(STOP_FOREGROUND_REMOVE)
_isForeground = false
}
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID)
_started = false
super.stopSelf()
Logger.i(TAG, "closeDownloadSession");
stopForeground(STOP_FOREGROUND_REMOVE);
_notificationManager?.cancel(DOWNLOAD_NOTIF_ID);
stopService();
_started = false;
super.stopSelf();
}
override fun onDestroy() {
Logger.i(TAG, "onDestroy");
_instance = null;
@@ -49,8 +49,6 @@ 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.*
@@ -137,12 +135,8 @@ class StateApp {
return _scope;
}
val scope: CoroutineScope get() {
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;
}
val thisScope = scopeOrNull
?: throw IllegalStateException("Attempted to use a global lifetime scope while MainActivity is no longer available");
return thisScope;
}
val scopeGetter: ()->CoroutineScope get() {
@@ -387,29 +381,6 @@ class StateApp {
_cacheDirectory?.let { ApiMethods.initCache(it) };
}
Logger.i(TAG, "MainApp Starting: Initializing [ModerationsManager]");
ModerationsManager.initialize(context);
Logger.i(TAG, "MainApp Starting: Setting [ModerationLevelProvider]");
ApiMethods.setModerationLevelProvider {
try {
ModerationsManager.getInstance().getCurrentModerationLevels()
} catch (e: IllegalStateException) {
Logger.e(TAG, "Failed to get moderation levels from manager", e);
null
}
}
Logger.i(TAG, "MainApp Starting: Setting [ModerationExemptSystemProvider]");
ApiMethods.setModerationExemptSystemProvider {
try {
StatePolycentric.instance.processHandle?.system?.toProto()?.toByteArray()?.toBase64Url()
} catch (e: Throwable) {
Logger.e(TAG, "Failed to get moderation exempt system from manager", e);
null
}
}
val logFile = File(context.filesDir, "log.txt");
if (Settings.instance.logging.logLevel > LogLevel.NONE.value) {
val fileLogConsumer = FileLogConsumer(logFile, LogLevel.fromInt(Settings.instance.logging.logLevel), false);
@@ -439,7 +439,7 @@ class StateDownloads {
} else {
throw NotImplementedError("Unsuported scheme");
}
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.language, subtitle.format, subtitles!!) else null;
return if (subtitles != null) SubtitleRawSource(subtitle.name, subtitle.format, subtitles!!) else null;
}
fun cleanupDownloads(): Pair<Int, Long> {
@@ -194,18 +194,17 @@ class StateHistory {
_remoteHistoryDatesStore.save();
}
fun syncRemoteHistory(plugin: JSClient): Int {
fun syncRemoteHistory(plugin: JSClient) {
if (plugin.capabilities.hasGetUserHistory &&
plugin.isLoggedIn) {
Logger.i(TAG, "Syncing remote history for plugin [${plugin.name}]");
val hist = StatePlatform.instance.getUserHistory(plugin.id);
return syncRemoteHistory(plugin.id, hist, 100, 3);
syncRemoteHistory(plugin.id, hist, 100, 3);
}
return 0;
}
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int): Int {
fun syncRemoteHistory(pluginId: String, videos: IPager<IPlatformContent>, maxVideos: Int, maxPages: Int) {
val lastDate = _remoteHistoryDatesStore.get(pluginId) ?: OffsetDateTime.MIN;
val maxVideosCount = if(maxVideos <= 0) 500 else maxVideos;
val maxPageCount = if(maxPages <= 0) 3 else maxPages;
@@ -273,14 +272,12 @@ 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,11 +177,16 @@ class StatePlatform {
}
withContext(Dispatchers.IO) {
var toDisables = mutableListOf<IPlatformClient>();
var enabled: Array<String>;
synchronized(_clientsLock) {
for(e in _enabledClients) {
toDisables.add(e);
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"));
}
}
_enabledClients.clear();
@@ -231,18 +236,6 @@ 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);
}
}
}
};
}
@@ -355,11 +348,11 @@ class StatePlatform {
StateApp.instance.handleCaptchaException(c, ex);
}
var toDisable: IPlatformClient? = null;
synchronized(_clientsLock) {
if (_enabledClients.contains(client)) {
_enabledClients.remove(client);
toDisable = client;
client.disable();
onSourceDisabled.emit(client);
newClient.initialize();
_enabledClients.add(newClient);
}
@@ -367,18 +360,6 @@ 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;
};
@@ -519,7 +500,7 @@ class StatePlatform {
.toList()
.associateWith { 1f };
val pager = MultiDistributionContentPager(pages, 2);
val pager = MultiDistributionContentPager(pages);
pager.initialize();
return pager;
}
@@ -179,9 +179,8 @@ class StatePlugins {
}
StateApp.instance.scope.launch(Dispatchers.IO) {
StatePlatform.instance.reloadClient(context, id) {
afterLogin.invoke();
}
StatePlatform.instance.reloadClient(context, id);
afterLogin.invoke();
}
};
return true;
@@ -402,25 +401,18 @@ 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) {
@@ -483,7 +475,6 @@ class StatePlugins {
delay(500);
val client = ManagedHttpClient();
client.setTimeout(10000);
try {
withContext(Dispatchers.Main) {
onProgress.invoke("Validating script", 0.25);
@@ -498,14 +489,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;
@@ -529,7 +520,9 @@ class StatePlugins {
if(id == StateDeveloper.DEV_ID)
throw IllegalStateException("Attempted to retrieve a persistent developer plugin, this is not allowed");
return _plugins.findItem { it.config.id == id };
synchronized(_plugins) {
return _plugins.findItem { it.config.id == id };
}
}
fun getPlugins(): List<SourcePluginDescriptor> {
return _plugins.getItems();
@@ -538,10 +531,12 @@ class StatePlugins {
fun deletePlugin(id: String) {
synchronized(_pluginScripts) {
_pluginScripts.deleteFile(id);
val plugins = _plugins.findItems { it.config.id == id };
for(plugin in plugins)
_plugins.delete(plugin);
synchronized(_plugins) {
_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,7 +28,6 @@ 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
@@ -47,10 +46,8 @@ 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
@@ -70,8 +67,6 @@ 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) {
@@ -178,15 +173,6 @@ 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()
@@ -573,11 +559,6 @@ class StatePolycentric {
};
}
fun cleanup() {
_backgroundJob.cancel()
_commentPool.shutdown()
}
companion object {
private const val TAG = "StatePolycentric";
@@ -57,12 +57,9 @@ class StateSync {
return
}
var relayServerUrl = Settings.instance.synchronization.syncServer;
Logger.i(TAG, "Relay used: ${relayServerUrl}");
syncService = SyncService(
SERVICE_NAME,
relayServerUrl,
RELAY_SERVER,
RELAY_PUBLIC_KEY,
APP_ID,
StoreBasedSyncDatabaseProvider(),
@@ -6,7 +6,6 @@ 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
@@ -18,23 +17,14 @@ 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
@@ -74,7 +64,11 @@ class SyncService(
private val database: ISyncDatabaseProvider,
private val settings: SyncServiceSettings = SyncServiceSettings()
) {
private var _serverSocket: ServerSocketChannel? = null
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 val _sessions: MutableMap<String, SyncSession> = mutableMapOf()
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
@@ -88,10 +82,10 @@ class SyncService(
private val _pairingCode: String? = generateReadablePassword(8)
val pairingCode: String? get() = _pairingCode
private var _relaySession: SyncSocketSession? = null
private val _remotePendingStatusUpdateRelayed = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private val _remotePendingStatusUpdateDirect = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _threadRelay: Thread? = null
private val _remotePendingStatusUpdate = mutableMapOf<String, (complete: Boolean?, message: String) -> Unit>()
private var _nsdManager: NsdManager? = null
@Volatile private var _scope: CoroutineScope? = null
private var _scope: CoroutineScope? = null
private val _mdnsCache = mutableMapOf<String, SyncDeviceInfo>()
private var _discoveryListener: NsdManager.DiscoveryListener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(regType: String) {
@@ -222,12 +216,11 @@ class SyncService(
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context) {
if (_scope != null) {
Log.i(TAG, "Already started.")
if (_started) {
Logger.i(TAG, "Already started.")
return
}
Log.i(TAG, "Start SyncService.")
_started = true
_scope = CoroutineScope(Dispatchers.IO)
try {
@@ -301,30 +294,27 @@ class SyncService(
private fun startListener() {
serverSocketFailedToStart = false
serverSocketStarted = false
_scope?.launch(Dispatchers.IO) {
_thread = Thread {
try {
val serverSocket = ServerSocketChannel.open()
serverSocket.socket().bind(InetSocketAddress("0.0.0.0", settings.listenerPort))
val serverSocket = ServerSocket(settings.listenerPort)
_serverSocket = serverSocket
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
while (isActive) {
while (_started) {
val socket = serverSocket.accept()
//TODO: Switch to SocketChannel?
val session = createSocketSession(socket.socket(), true)
val session = createSocketSession(socket, true)
session.startAsResponder()
}
} catch (e: ClosedChannelException) {
// normal shutdown
serverSocketStarted = false
} catch (e: Throwable) {
Log.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true
} finally {
serverSocketStarted = false
}
}
}.apply { start() }
}
private fun startMdnsRetryLoop() {
@@ -332,44 +322,43 @@ class SyncService(
discoverServices(serviceName, NsdManager.PROTOCOL_DNS_SD, _discoveryListener)
}
_scope?.launch(Dispatchers.IO) {
while (isActive) {
_mdnsThread = Thread {
while (_started) {
try {
val now = System.currentTimeMillis()
val pairs = synchronized (_mdnsCache) { _mdnsCache.toList() }
for ((pkey, info) in pairs) {
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
synchronized(_mdnsCache) {
for ((pkey, info) in _mdnsCache) {
if (!database.isAuthorized(pkey) || getLinkType(pkey) == LinkType.Direct) continue
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
}
if (now - last > 30_000L) {
synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] = now
val last = synchronized(_lastConnectTimesMdns) {
_lastConnectTimesMdns[pkey] ?: 0L
}
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)
if (now - last > 30_000L) {
_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)
}
}
}
}
} catch (ex: Throwable) {
Log.e(TAG, "Error in MDNS retry loop", ex)
Logger.e(TAG, "Error in MDNS retry loop", ex)
}
delay(5000)
Thread.sleep(5000)
}
}
}.apply { start() }
}
private fun startConnectLastLoop() {
_scope?.launch(Dispatchers.IO) {
_connectThread = Thread {
Log.i(TAG, "Running auto reconnector")
while (isActive) {
val authorizedDevices = database.getAllAuthorizedDevices()?.toList() ?: listOf()
while (_started) {
val authorizedDevices = database.getAllAuthorizedDevices() ?: arrayOf()
val addressesToConnect = authorizedDevices.mapNotNull {
val connectedDirectly = getLinkType(it) == LinkType.Direct
if (connectedDirectly) {
@@ -393,26 +382,26 @@ class SyncService(
_lastConnectTimesIp[connectPair.first] = now
}
Log.i(TAG, "Attempting to connect to authorized device by last known IP '${connectPair.first}' with pkey=${connectPair.first}")
Logger.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) {
Log.i(TAG, "Failed to connect to " + connectPair.first, e)
Logger.i(TAG, "Failed to connect to " + connectPair.first, e)
}
}
delay(5000)
Thread.sleep(5000)
}
}
}.apply { start() }
}
private fun startRelayLoop() {
relayConnected = false
_scope?.launch(Dispatchers.IO) {
_threadRelay = Thread {
try {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0;
while (isActive) {
while (_started) {
try {
Log.i(TAG, "Starting relay session...")
relayConnected = false
@@ -476,7 +465,7 @@ class SyncService(
Thread {
try {
while (isActive && !socketClosed) {
while (_started && !socketClosed) {
val unconnectedAuthorizedDevices =
database.getAllAuthorizedDevices()
?.filter {
@@ -514,14 +503,27 @@ class SyncService(
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp }
if (getLinkType(targetKey) != LinkType.Direct && connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
launch(Dispatchers.IO) {
Thread {
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) {
@@ -585,7 +587,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 {
@@ -697,21 +699,14 @@ 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 ->
sendRemotePendingStatusUpdate(remotePublicKey, true, "Authorized")
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(true, "Authorized")
}
if (isNewSession) {
it.remoteDeviceName?.let { remoteDeviceName ->
@@ -724,7 +719,10 @@ class SyncService(
onAuthorized?.invoke(it, isNewlyAuthorized, isNewSession)
},
onUnauthorized = {
sendRemotePendingStatusUpdate(remotePublicKey, false, "Unauthorized")
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Unauthorized")
}
onUnauthorized?.invoke(it)
},
onConnectedChanged = { it, connected ->
@@ -735,7 +733,9 @@ class SyncService(
Logger.i(TAG, "$remotePublicKey closed")
removeSession(it.remotePublicKey)
sendRemotePendingStatusUpdate(remotePublicKey, false, "Connection closed")
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate.remove(remotePublicKey)?.invoke(false, "Connection closed")
}
onClose?.invoke(it)
},
@@ -757,67 +757,42 @@ class SyncService(
fun getAllAuthorizedDevices(): Array<String>? = database.getAllAuthorizedDevices()
fun removeAuthorizedDevice(publicKey: String) = database.removeAuthorizedDevice(publicKey)
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
}
}
//TODO: Do not try relayed channel here only for pairing mode?
rs.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
}
fun connect(deviceInfo: SyncDeviceInfo, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null) {
try {
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate, timeout_ms)
connect(deviceInfo.addresses, deviceInfo.port, deviceInfo.publicKey, deviceInfo.pairingCode, onStatusUpdate)
} catch (e: Throwable) {
Log.e(TAG, "Failed to connect directly", e)
Logger.e(TAG, "Failed to connect directly", e)
val relaySession = _relaySession
if (relaySession != null && Settings.instance.synchronization.pairThroughRelay) {
onStatusUpdate?.invoke(null, "Connecting via relay...")
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())
runBlocking {
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[deviceInfo.publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
}
}
relaySession.startRelayedChannel(deviceInfo.publicKey.base64ToByteArray().toBase64(), appId, deviceInfo.pairingCode)
}
} else {
throw e
}
}
}
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})...")
fun connect(addresses: Array<String>, port: Int, publicKey: String, pairingCode: String?, onStatusUpdate: ((complete: Boolean?, message: String) -> Unit)? = null): SyncSocketSession {
onStatusUpdate?.invoke(null, "Connecting directly...")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port, timeout_ms) ?: throw Exception("Failed to connect")
val socket = getConnectedSocket(addresses.map { InetAddress.getByName(it) }, port) ?: throw Exception("Failed to connect")
onStatusUpdate?.invoke(null, "Handshaking...")
val session = createSocketSession(socket, false)
if (onStatusUpdate != null) {
synchronized(_remotePendingStatusUpdateDirect) {
_remotePendingStatusUpdateDirect[publicKey.base64ToByteArray().toBase64()] = onStatusUpdate
synchronized(_remotePendingStatusUpdate) {
_remotePendingStatusUpdate[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
}
@@ -836,8 +811,6 @@ class SyncService(
synchronized(_sessions) {
_sessions.clear()
}
_remotePendingStatusUpdateDirect.clear()
_remotePendingStatusUpdateRelayed.clear()
}
private fun getDeviceName(): String {
@@ -56,7 +56,6 @@ 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,7 +4,6 @@ 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
@@ -29,8 +28,6 @@ import kotlinx.coroutines.launch
class ToggleBar : LinearLayout {
private val _tagsContainer: LinearLayout;
private var allowLongPress: Boolean = false;
override fun onAttachedToWindow() {
super.onAttachedToWindow();
}
@@ -51,31 +48,12 @@ 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, button.tag);
this.setInfo(button.icon, button.name, button.isActive, button.isButton);
else if(button.iconVariable != null)
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton, button.tag);
this.setInfo(button.iconVariable, button.name, button.isActive, button.isButton);
else
this.setInfo(button.name, button.isActive, button.isButton, button.tag);
this.setInfo(button.name, button.isActive, button.isButton);
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);
});
}
});
}
}
@@ -85,18 +63,16 @@ 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, actionLong: ((ToggleTagView, List<ToggleTagView>, Boolean)->Unit)? = null) {
constructor(name: String, icon: ImageVariable?, isActive: Boolean = false, action: (ToggleTagView, Boolean)->Unit) {
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) {
@@ -104,7 +80,6 @@ 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) {
@@ -112,7 +87,6 @@ class ToggleBar : LinearLayout {
this.icon = 0;
this.iconVariable = null;
this.action = action;
this.actionLong = null;
this.isActive = isActive;
}
@@ -1,11 +1,6 @@
package com.futo.platformplayer.views.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.GestureDetector
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -16,7 +11,6 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
import com.futo.platformplayer.api.media.models.comments.LazyComment
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -103,15 +97,6 @@ class CommentViewHolder : ViewHolder {
StatePolycentric.instance.updateLikeMap(c.reference, args.hasLiked, args.hasDisliked)
};
_layoutComment.setOnLongClickListener {
val clipboard = viewGroup.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = comment?.message.orEmpty()
val clip = ClipData.newPlainText("Comment", text)
clipboard.setPrimaryClip(clip)
UIDialogs.toast(viewGroup.context, "Copied", false)
true
}
_creatorThumbnail.onClick.subscribe {
val c = comment ?: return@subscribe;
onAuthorClick.emit(c);
@@ -135,7 +120,7 @@ class CommentViewHolder : ViewHolder {
onDelete.emit(c);
}
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context)
_textBody.setPlatformPlayerLinkMovementMethod(viewGroup.context);
}
fun bind(comment: IPlatformComment, readonly: Boolean) {
@@ -4,19 +4,21 @@ 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.Settings
import com.futo.platformplayer.UIDialogs
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.constructs.Event1
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.constructs.Event2
import androidx.core.view.isVisible
import com.futo.platformplayer.UIDialogs
class DeviceViewHolder : ViewHolder {
private val _layoutDevice: FrameLayout;
@@ -54,18 +56,16 @@ class DeviceViewHolder : ViewHolder {
val connect = {
device?.let { dev ->
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")
}
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
}
} catch (e: Throwable) {
Logger.e(TAG, "Failed to connect: $e")
}
}
}
@@ -81,25 +81,15 @@ class DeviceViewHolder : ViewHolder {
}
fun bind(d: CastingDevice, isOnlineDevice: Boolean, isPinnedDevice: Boolean) {
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";
}
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";
}
_textName.text = d.name;
@@ -146,8 +136,4 @@ class DeviceViewHolder : ViewHolder {
device = d;
}
companion object {
private val TAG = "DeviceViewHolder"
}
}
@@ -1,25 +0,0 @@
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"
}
}
@@ -1,117 +0,0 @@
package com.futo.platformplayer.views.buttons
import android.content.Context
import android.graphics.Bitmap
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.constructs.Event0
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.shape.ShapeAppearanceModel
class ShortsButton : LinearLayout {
private val _root: LinearLayout;
private val _icon: ImageView;
private val _textPrimary: TextView;
val onClick = Event0();
var iconId: Int? = null;
constructor(context : Context, text: String, icon: Int, action: ()->Unit) : super(context) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.button_icon);
_textPrimary = findViewById(R.id.button_text);
_root = findViewById(R.id.root);
withPrimaryText(text);
withIcon(icon);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
action();
onClick.emit();
UIDialogs.toast("Clicked button: " + _textPrimary.text);
};
}
}
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_shorts_button, this);
_icon = findViewById(R.id.image_icon);
_textPrimary = findViewById(R.id.text_title);
_root = findViewById(R.id.root);
_root.apply {
isClickable = true;
setOnClickListener {
if(!isEnabled)
return@setOnClickListener;
onClick.emit();
};
}
val attrArr = context.obtainStyledAttributes(attrs, R.styleable.ShortsButton, 0, 0);
val attrIconRef = attrArr.getResourceId(R.styleable.ShortsButton_buttonIcon_s, -1);
val attrText = attrArr.getText(R.styleable.ShortsButton_buttonText_s) ?: "";
attrArr.recycle()
withIcon(attrIconRef);
withPrimaryText(attrText.toString());
}
fun withMargin(bottom: Int, side: Int = 0): ShortsButton {
setPadding(side, 0, side, bottom)
return this;
}
fun withPrimaryText(text: String): ShortsButton {
_textPrimary.text = text;
if(text.isNullOrBlank())
_textPrimary.visibility = View.GONE;
else
_textPrimary.visibility = View.VISIBLE;
return this;
}
fun withIcon(resourceId: Int): ShortsButton {
if (resourceId != -1) {
_icon.visibility = View.VISIBLE;
_icon.setImageResource(resourceId);
} else
_icon.visibility = View.GONE;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
iconId = resourceId;
return this;
}
fun withIcon(bitmap: Bitmap): ShortsButton {
_icon.visibility = View.VISIBLE;
_icon.setImageBitmap(bitmap);
iconId = -1;
_icon.scaleType = ImageView.ScaleType.CENTER_CROP;
return this;
}
fun setButtonEnabled(enabled: Boolean) {
if(enabled) {
alpha = 1f;
isEnabled = true;
isClickable = true;
}
else {
alpha = 0.5f;
isEnabled = false;
isClickable = false;
}
}
}
@@ -2,7 +2,12 @@ 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,13 +21,14 @@ 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.CastConnectionState
import com.futo.platformplayer.casting.AirPlayCastingDevice
import com.futo.platformplayer.casting.ChromecastCastingDevice
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.logging.Logger
import com.futo.platformplayer.states.StateHistory
import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.views.TargetTapLoaderView
import com.futo.platformplayer.views.behavior.GestureControlView
@@ -35,6 +36,7 @@ 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 {
@@ -68,7 +70,6 @@ class CastView : ConstraintLayout {
val onPrevious = Event0();
val onNext = Event0();
val onTimeJobTimeChanged_s = Event1<Long>()
val loaderGameVisibilityChanged = Event1<Boolean>();
@OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@@ -91,7 +92,6 @@ class CastView : ConstraintLayout {
_gestureControlView = findViewById(R.id.gesture_control);
_loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE
loaderGameVisibilityChanged.emit(false)
_gestureControlView.fullScreenGestureEnabled = false
_gestureControlView.setupTouchArea();
@@ -99,30 +99,19 @@ class CastView : ConstraintLayout {
val d = StateCasting.instance.activeDevice ?: return@subscribe;
_speedHoldWasPlaying = d.isPlaying
_speedHoldPrevRate = d.speed
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")
}
if (d.canSetSpeed)
d.changeSpeed(Settings.instance.playback.getHoldPlaybackSpeed())
d.resumeVideo()
}
_gestureControlView.onSpeedHoldEnd.subscribe {
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")
}
val d = StateCasting.instance.activeDevice ?: return@subscribe;
if (!_speedHoldWasPlaying) d.pauseVideo()
d.changeSpeed(_speedHoldPrevRate)
}
_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 {
@@ -231,9 +220,22 @@ class CastView : ConstraintLayout {
stopTimeJob()
if(isPlaying) {
StateCasting.instance.startUpdateTimeJob(
onTimeJobTimeChanged_s
) { setTime(it) }
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())
}
}
}
if (!_inPictureInPicture) {
_buttonPause.visibility = View.VISIBLE;
@@ -321,21 +323,14 @@ class CastView : ConstraintLayout {
if (isLoading) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader()
loaderGameVisibilityChanged.emit(true)
} else {
_loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader()
loaderGameVisibilityChanged.emit(false)
}
}
fun setLoading(expectedDurationMs: Int) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
loaderGameVisibilityChanged.emit(true)
}
companion object {
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, AnnotationTarget.FUNCTION)
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class AdvancedField();
@@ -14,7 +14,6 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.getDataLinkFromUrl
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.views.IdenticonView
import userpackage.Protocol
@@ -83,14 +82,14 @@ class CreatorThumbnail : ConstraintLayout {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.crossfade()
.into(_imageChannelThumbnail)
.into(_imageChannelThumbnail);
} else {
Glide.with(_imageChannelThumbnail)
.load(url)
.placeholder(R.drawable.placeholder_channel_thumbnail)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.into(_imageChannelThumbnail);
}
}
@@ -50,29 +50,6 @@ 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,7 +20,6 @@ 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) {
@@ -33,13 +32,6 @@ 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,16 +23,12 @@ 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);
@@ -40,25 +36,10 @@ class ToggleTagView : LinearLayout {
_textTag = findViewById(R.id.text_tag);
_image = findViewById(R.id.image_tag);
_root.setOnClickListener {
handleClick();
if(!isButton)
setToggle(!isActive);
onClick.emit(this, isActive);
}
_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) {
@@ -89,10 +70,9 @@ 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, tag: String? = null) {
fun setInfo(imageResource: Int, text: String, isActive: Boolean, isButton: Boolean = false) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -100,9 +80,8 @@ 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, tag: String? = null) {
fun setInfo(image: ImageVariable, text: String, isActive: Boolean, isButton: Boolean = false) {
_text = text;
_textTag.text = text;
setToggle(isActive);
@@ -110,15 +89,13 @@ 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, tag: String? = null) {
fun setInfo(text: String, isActive: Boolean, isButton: Boolean = false) {
_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;
}
}
@@ -6,17 +6,14 @@ import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.animation.LinearInterpolator
import androidx.annotation.Dimension
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
@@ -60,10 +57,6 @@ class FutoShortPlayer(context: Context, attrs: AttributeSet? = null) :
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
onPlaybackStateChanged.emit(player.playbackState)
}
if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) {
onPlayChanged.emit(player.isPlaying)
}
}
}
@@ -72,13 +65,6 @@ 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) {
player = StatePlayer.instance.getShortPlayerOrCreate(context)
player.player.repeatMode = Player.REPEAT_MODE_ONE
@@ -164,8 +164,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _loaderGame: TargetTapLoaderView
val loaderGameVisibilityChanged = Event1<Boolean>();
@OptIn(UnstableApi::class)
constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) {
LayoutInflater.from(context).inflate(R.layout.video_view, this, true);
@@ -208,7 +206,6 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_loaderGame = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE
loaderGameVisibilityChanged.emit(false)
_control_chapter.setOnClickListener {
_currentChapter?.let {
@@ -897,30 +894,24 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
if (isLoading) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader()
loaderGameVisibilityChanged.emit(true)
} else {
_loaderGame.visibility = View.GONE
_loaderGame.stopAndResetLoader()
loaderGameVisibilityChanged.emit(false)
}
}
override fun setLoading(expectedDurationMs: Int) {
_loaderGame.visibility = View.VISIBLE
_loaderGame.startLoader(expectedDurationMs.toLong())
loaderGameVisibilityChanged.emit(true)
}
override fun switchToVideoMode() {
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)
@@ -937,6 +928,5 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
}
})
}
*/
}
}
@@ -64,12 +64,7 @@ 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
@@ -485,8 +480,6 @@ 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}]");
}
@@ -503,8 +496,6 @@ 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}]");
}
@@ -523,23 +514,6 @@ 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) {
@@ -644,7 +618,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
setLoading(true)
}
}
val generated = generatedDef.awaitCancelConverted();
val generated = generatedDef.await();
if (_swapIdVideo.get() != swapId) {
return@launch
}
@@ -733,23 +707,6 @@ 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) {
@@ -808,7 +765,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
setLoading(true)
}
}
val generated = generatedDef.awaitCancelConverted();
val generated = generatedDef.await();
if (_swapIdAudio.get() != swapId) {
return@launch
}
@@ -914,13 +871,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage);
}
fun getPreferredSubtitleSource(video: IPlatformVideoDetails, preferredLanguage: String?): ISubtitleSource? {
return VideoHelper.selectBestSubtitleSource(video.subtitles, preferredLanguage);
}
@OptIn(UnstableApi::class)
private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean {
val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null;
val sourceVideo = _lastVideoMediaSource
val sourceAudio = _lastAudioMediaSource;
val sourceSubs = _lastSubtitleMediaSource;
@@ -1,6 +0,0 @@
<?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>
@@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M240,560L720,560L720,480L240,480L240,560ZM240,440L720,440L720,360L240,360L240,440ZM240,320L720,320L720,240L240,240L240,320ZM880,880L720,720L160,720Q127,720 103.5,696.5Q80,673 80,640L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,880ZM160,640L754,640L800,685L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640ZM160,640Q160,640 160,640Q160,640 160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Z"/>
</vector>
-14
View File
@@ -1,14 +0,0 @@
<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>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
-11
View File
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:strokeColor="#222"
android:strokeWidth="20"
android:pathData="M240,120L680,120L680,640L400,920L350,870Q343,863 338.5,851Q334,839 334,828L334,814L378,640L120,640Q88,640 64,616Q40,592 40,560L40,480Q40,473 42,465Q44,457 46,450L166,168Q175,148 196,134Q217,120 240,120ZM600,200L240,200Q240,200 240,200Q240,200 240,200L120,480L120,560Q120,560 120,560Q120,560 120,560L480,560L426,780L600,606L600,200ZM600,606L600,606L600,560L600,560Q600,560 600,560Q600,560 600,560L600,480L600,200Q600,200 600,200Q600,200 600,200L600,200L600,606ZM680,640L680,560L800,560L800,200L680,200L680,120L880,120L880,640L680,640Z"/>
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@color/colorPrimary"
android:pathData="M240,120L640,120L640,640L360,920L310,870Q303,863 298.5,851Q294,839 294,828L294,814L338,640L120,640Q88,640 64,616Q40,592 40,560L40,480Q40,473 41.5,465Q43,457 46,450L166,168Q175,148 196,134Q217,120 240,120ZM720,640L720,120L880,120L880,640L720,640Z"/>
</vector>

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