mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 21:12:39 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e29999ef | |||
| f3f13a71dc | |||
| 5155423a1e | |||
| a7d558e48d | |||
| 7afd75c712 | |||
| 10a661ad4c | |||
| 201fe6f0df | |||
| f76a5b5f01 | |||
| 3a7e477e9b | |||
| b1aae244de | |||
| 7ebd8f13c2 | |||
| 1768d73c01 | |||
| ebcb894011 |
@@ -333,7 +333,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
"Submit logs to help us narrow down issues", 1
|
||||
)
|
||||
fun submitLogs() {
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!Logger.submitLogs()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -406,6 +406,33 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
|
||||
|
||||
@FormField("External Storage", FieldForm.GROUP, "", 12)
|
||||
var storage = Storage();
|
||||
@Serializable
|
||||
class Storage {
|
||||
var storage_general: String? = null;
|
||||
var storage_download: String? = null;
|
||||
|
||||
fun getStorageGeneralUri(): Uri? = storage_general?.let { Uri.parse(it) };
|
||||
fun getStorageDownloadUri(): Uri? = storage_download?.let { Uri.parse(it) };
|
||||
fun isStorageMainValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageGeneralUri());
|
||||
fun isStorageDownloadValid(context: Context): Boolean = StateApp.instance.isValidStorageUri(context, getStorageDownloadUri());
|
||||
|
||||
@FormField("Change external General directory", FieldForm.BUTTON, "Change the external directory for general files, used for persistent files like auto-backup", 3)
|
||||
fun changeStorageGeneral() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||
}
|
||||
}
|
||||
@FormField("Change external Downloads directory", FieldForm.BUTTON, "Change the external storage for download files, used for exported download files", 4)
|
||||
fun changeStorageDownload() {
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@FormField("Auto Update", "group", "Configure the auto updater", 12)
|
||||
var autoUpdate = AutoUpdate();
|
||||
@Serializable
|
||||
@@ -462,7 +489,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun viewChangelog() {
|
||||
UIDialogs.toast("Retrieving changelog");
|
||||
SettingsActivity.getActivity()?.let {
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val version = StateUpdate.instance.downloadVersionCode(ManagedHttpClient()) ?: return@launch;
|
||||
Logger.i(TAG, "Version retrieved $version");
|
||||
@@ -511,7 +538,9 @@ class Settings : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField("Set Automatic Backup", FieldForm.BUTTON, "Configure daily backup in case of catastrophic failure. (Written to the external Grayjay directory)", 1)
|
||||
fun configureAutomaticBackup() {
|
||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!);
|
||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
||||
SettingsActivity.getActivity()?.reloadSettings();
|
||||
};
|
||||
}
|
||||
@FormField("Restore Automatic Backup", FieldForm.BUTTON, "Restore a previous automatic backup", 2)
|
||||
fun restoreAutomaticBackup() {
|
||||
@@ -542,6 +571,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
StatePayment.instance.clearLicenses();
|
||||
SettingsActivity.getActivity()?.let {
|
||||
UIDialogs.toast(it, "Licenses cleared, might require app restart");
|
||||
it.reloadSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.dialogs.*
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -90,11 +92,25 @@ class UIDialogs {
|
||||
}
|
||||
|
||||
|
||||
fun showAutomaticBackupDialog(context: Context) {
|
||||
val dialog = AutomaticBackupDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||
dialog.show();
|
||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
||||
val dialogAction: ()->Unit = {
|
||||
val dialog = AutomaticBackupDialog(context);
|
||||
registerDialogOpened(dialog);
|
||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
||||
dialog.show();
|
||||
};
|
||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, "An old backup is available", "Would you like to restore this backup?", null, 0,
|
||||
UIDialogs.Action("Cancel", {}), //To nothing
|
||||
UIDialogs.Action("Override", {
|
||||
dialogAction();
|
||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||
UIDialogs.Action("Restore", {
|
||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
else {
|
||||
dialogAction();
|
||||
}
|
||||
}
|
||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||
val dialog = AutomaticRestoreDialog(context, scope);
|
||||
@@ -134,10 +150,10 @@ class UIDialogs {
|
||||
val buttonView = TextView(context);
|
||||
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
||||
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
||||
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics);
|
||||
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
||||
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||
if(actions.size > 1)
|
||||
this.marginEnd = dp28;
|
||||
this.marginEnd = if(actions.size > 2) dp14 else dp28;
|
||||
};
|
||||
buttonView.setTextColor(Color.WHITE);
|
||||
buttonView.textSize = 14f;
|
||||
@@ -151,8 +167,9 @@ class UIDialogs {
|
||||
ActionStyle.DANGEROUS_TEXT -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.pastel_red))
|
||||
else -> buttonView.setTextColor(ContextCompat.getColor(context, R.color.colorPrimary))
|
||||
}
|
||||
val paddingSpecialButtons = if(actions.size > 2) dp14 else dp28;
|
||||
if(act.style != ActionStyle.NONE && act.style != ActionStyle.DANGEROUS_TEXT)
|
||||
buttonView.setPadding(dp28, dp10, dp28, dp10);
|
||||
buttonView.setPadding(paddingSpecialButtons, dp10, paddingSpecialButtons, dp10);
|
||||
else
|
||||
buttonView.setPadding(dp10, dp10, dp10, dp10);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.icu.util.Output
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.os.OperationCanceledException
|
||||
@@ -15,8 +16,12 @@ import android.view.WindowInsetsController
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||
@@ -51,6 +56,11 @@ fun findNonRuntimeException(ex: Throwable?): Throwable? {
|
||||
return ex;
|
||||
}
|
||||
|
||||
fun warnIfMainThread(context: String) {
|
||||
if(BuildConfig.DEBUG && Looper.myLooper() == Looper.getMainLooper())
|
||||
Logger.w(V8Plugin.TAG, "JAVASCRIPT ON MAIN THREAD\nAt: ${context}\n" + Thread.currentThread().stackTrace);
|
||||
}
|
||||
|
||||
fun ensureNotMainThread() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||
@@ -63,8 +73,20 @@ fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
}
|
||||
|
||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
|
||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||
fun DocumentFile.copyTo(context: Context, file: DocumentFile) = this.getInputStream(context).use { input ->
|
||||
file.getOutputStream(context)?.use { output -> input?.copyTo(output) }
|
||||
};
|
||||
fun DocumentFile.readBytes(context: Context) = this.getInputStream(context).use { input -> input?.readBytes() };
|
||||
fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.contentResolver.openOutputStream(this.uri)?.use {
|
||||
it.write(byteArray);
|
||||
it.flush();
|
||||
};
|
||||
|
||||
fun loadBitmap(url: String): Bitmap {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.futo.platformplayer.activities
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
|
||||
interface IWithResultLauncher {
|
||||
fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -24,6 +27,7 @@ import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event3
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.*
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
@@ -48,7 +52,7 @@ import java.io.StringWriter
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.*
|
||||
|
||||
class MainActivity : AppCompatActivity {
|
||||
class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
|
||||
//TODO: Move to dimensions
|
||||
private val HEIGHT_MENU_DP = 48f;
|
||||
@@ -364,6 +368,7 @@ class MainActivity : AppCompatActivity {
|
||||
//startActivity(Intent(this, TestActivity::class.java));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
@@ -387,7 +392,7 @@ class MainActivity : AppCompatActivity {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.i(TAG, "onResume")
|
||||
Logger.v(TAG, "onResume")
|
||||
|
||||
val curOrientation = _orientationManager.orientation;
|
||||
|
||||
@@ -403,13 +408,10 @@ class MainActivity : AppCompatActivity {
|
||||
val videoToOpen = StateSaved.instance.videoToOpen;
|
||||
|
||||
if (_wasStopped) {
|
||||
Logger.i(TAG, "_wasStopped is true");
|
||||
Logger.i(TAG, "set _wasStopped = false");
|
||||
_wasStopped = false;
|
||||
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
|
||||
if (videoToOpen != null && _fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
|
||||
Logger.i(TAG, "onResume videoToOpen=$videoToOpen");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(videoToOpen.url)) {
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(videoToOpen.url, videoToOpen.timeSeconds, false));
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
@@ -422,13 +424,13 @@ class MainActivity : AppCompatActivity {
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Logger.i(TAG, "onPause")
|
||||
Logger.v(TAG, "onPause")
|
||||
_isVisible = false;
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
Logger.i(TAG, "_wasStopped = true");
|
||||
Logger.v(TAG, "_wasStopped = true");
|
||||
_wasStopped = true;
|
||||
}
|
||||
|
||||
@@ -717,22 +719,20 @@ class MainActivity : AppCompatActivity {
|
||||
}
|
||||
_fragVideoDetail.onOrientationChanged(OrientationManager.Orientation.PORTRAIT);
|
||||
}
|
||||
|
||||
Logger.i(TAG, "onRestart5");
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
|
||||
|
||||
val isStop: Boolean = lifecycle.currentState == Lifecycle.State.CREATED;
|
||||
Logger.i(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged isInPictureInPictureMode=$isInPictureInPictureMode isStop=$isStop")
|
||||
_fragVideoDetail?.onPictureInPictureModeChanged(isInPictureInPictureMode, isStop, newConfig);
|
||||
Logger.i(TAG, "onPictureInPictureModeChanged Ready");
|
||||
Logger.v(TAG, "onPictureInPictureModeChanged Ready");
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy();
|
||||
Logger.i(TAG, "onDestroy")
|
||||
Logger.v(TAG, "onDestroy")
|
||||
|
||||
_orientationManager.disable();
|
||||
|
||||
@@ -892,6 +892,28 @@ class MainActivity : AppCompatActivity {
|
||||
_fragContainerMain.setPadding(0,0,0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, paddingBottom, resources.displayMetrics).toInt());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
||||
private var requestCode: Int? = -1;
|
||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()) {
|
||||
result: ActivityResult ->
|
||||
val handler = synchronized(resultLauncherMap) {
|
||||
resultLauncherMap.remove(requestCode);
|
||||
}
|
||||
if(handler != null)
|
||||
handler(result);
|
||||
};
|
||||
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
|
||||
synchronized(resultLauncherMap) {
|
||||
resultLauncherMap[code] = handler;
|
||||
}
|
||||
requestCode = code;
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "MainActivity"
|
||||
|
||||
|
||||
@@ -6,15 +6,22 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.views.Loader
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
||||
private lateinit var _form: FieldForm;
|
||||
private lateinit var _buttonBack: ImageButton;
|
||||
private lateinit var _loader: Loader;
|
||||
|
||||
private lateinit var _devSets: LinearLayout;
|
||||
private lateinit var _buttonDev: MaterialButton;
|
||||
@@ -30,9 +37,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||
_buttonBack = findViewById(R.id.button_back);
|
||||
_buttonDev = findViewById(R.id.button_dev);
|
||||
_devSets = findViewById(R.id.dev_settings);
|
||||
_loader = findViewById(R.id.loader);
|
||||
|
||||
_form.fromObject(Settings.instance);
|
||||
_form.onChanged.subscribe { field, value ->
|
||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
||||
_form.setObjectValues();
|
||||
Settings.instance.save();
|
||||
};
|
||||
@@ -56,6 +64,15 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
};
|
||||
_lastActivity = this;
|
||||
|
||||
reloadSettings();
|
||||
}
|
||||
|
||||
fun reloadSettings() {
|
||||
_loader.start();
|
||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
||||
_loader.stop();
|
||||
};
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -78,6 +95,28 @@ class SettingsActivity : AppCompatActivity() {
|
||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
||||
private var requestCode: Int? = -1;
|
||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()) {
|
||||
result: ActivityResult ->
|
||||
val handler = synchronized(resultLauncherMap) {
|
||||
resultLauncherMap.remove(requestCode);
|
||||
}
|
||||
if(handler != null)
|
||||
handler(result);
|
||||
};
|
||||
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
|
||||
synchronized(resultLauncherMap) {
|
||||
resultLauncherMap[code] = handler;
|
||||
}
|
||||
requestCode = code;
|
||||
resultLauncher.launch(intent);
|
||||
}
|
||||
|
||||
companion object {
|
||||
//TODO: Temporary for solving Settings issues
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
|
||||
@@ -63,7 +63,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
||||
}
|
||||
}.start();
|
||||
|
||||
Logger.i(TAG, "Started ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n"));
|
||||
Logger.i(TAG, "Started HTTP Server ${port}. \n" + getAddresses().map { it.hostAddress }.joinToString("\n"));
|
||||
}
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
|
||||
@@ -94,7 +94,10 @@ class LiveChatManager {
|
||||
if(_pager is JSLiveEventPager)
|
||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
if(newEvents.size > 0)
|
||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||
else
|
||||
Logger.v(TAG, "No new Live Events");
|
||||
|
||||
_scope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
@@ -11,12 +11,14 @@ class PlatformClientPool {
|
||||
private val _parent: JSClient;
|
||||
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
||||
private var _poolCounter = 0;
|
||||
private val _poolName: String?;
|
||||
|
||||
var isDead: Boolean = false
|
||||
private set;
|
||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||
|
||||
constructor(parentClient: IPlatformClient) {
|
||||
constructor(parentClient: IPlatformClient, name: String? = null) {
|
||||
_poolName = name;
|
||||
if(parentClient !is JSClient)
|
||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||
@@ -47,7 +49,7 @@ class PlatformClientPool {
|
||||
_poolCounter++;
|
||||
reserved = _pool.keys.find { !it.isBusy };
|
||||
if(reserved == null && _pool.size < capacity) {
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool (${_pool.size + 1}/${capacity})");
|
||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||
reserved = _parent.getCopy();
|
||||
reserved?.initialize();
|
||||
_pool[reserved!!] = _poolCounter;
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullable
|
||||
import com.futo.polycentric.core.combineHashCodes
|
||||
import okhttp3.internal.platform.Platform
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class PlatformID {
|
||||
@@ -40,6 +41,8 @@ class PlatformID {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NONE = PlatformID("Unknown", null);
|
||||
|
||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
|
||||
val contextName = "PlatformID";
|
||||
return PlatformID(
|
||||
@@ -49,5 +52,9 @@ class PlatformID {
|
||||
value.getOrDefault(config, "claimType", contextName, 0) ?: 0,
|
||||
value.getOrDefault(config, "claimFieldType", contextName, -1) ?: -1);
|
||||
}
|
||||
|
||||
fun asUrlID(url: String): PlatformID {
|
||||
return PlatformID("URL", url, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.futo.platformplayer.api.media
|
||||
|
||||
class PlatformMultiClientPool {
|
||||
private val _name: String;
|
||||
private val _maxCap: Int;
|
||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
|
||||
private var _isFake = false;
|
||||
|
||||
constructor(name: String, maxCap: Int = -1) {
|
||||
_name = name;
|
||||
_maxCap = if(maxCap > 0)
|
||||
maxCap
|
||||
else 99;
|
||||
}
|
||||
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||
if(_isFake)
|
||||
return parentClient;
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
||||
this.onDead.subscribe { client, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
_clientPools.remove(parentClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
_clientPools[parentClient]!!;
|
||||
};
|
||||
return pool.getClient(capacity.coerceAtMost(_maxCap));
|
||||
}
|
||||
|
||||
//Allows for testing disabling pooling without changing callers
|
||||
fun asFake(): PlatformMultiClientPool {
|
||||
_isFake = true;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ open class JSContent : IPlatformContent, IPluginSourced {
|
||||
|
||||
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();
|
||||
Logger.i("JSContent", "name=$name");
|
||||
author = PlatformAuthorLink.fromV8(_pluginConfig, _content.getOrThrow(config, "author", contextName));
|
||||
|
||||
val datetimeInt = _content.getOrThrow<Int>(config, "datetime", contextName).toLong();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import android.os.Looper
|
||||
import com.caoccao.javet.values.reference.V8ValueArray
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
abstract class JSPager<T> : IPager<T> {
|
||||
protected val plugin: V8Plugin;
|
||||
@@ -37,6 +40,8 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun nextPage() {
|
||||
warnIfMainThread("JSPager.nextPage");
|
||||
|
||||
pager = plugin.catchScriptErrors("[${plugin.config.name}] JSPager", "pager.nextPage()") {
|
||||
pager.invoke("nextPage", arrayOf<Any>());
|
||||
};
|
||||
@@ -53,6 +58,8 @@ abstract class JSPager<T> : IPager<T> {
|
||||
}
|
||||
|
||||
override fun getResults(): List<T> {
|
||||
warnIfMainThread("JSPager.getResults");
|
||||
|
||||
val previousResults = _lastResults?.let {
|
||||
if(!_resultChanged)
|
||||
return@let it;
|
||||
|
||||
+4
@@ -6,6 +6,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.warnIfMainThread
|
||||
|
||||
class JSPlaybackTracker: IPlaybackTracker {
|
||||
private val _config: IV8PluginConfig;
|
||||
@@ -20,6 +21,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
private set;
|
||||
|
||||
constructor(config: IV8PluginConfig, obj: V8ValueObject) {
|
||||
warnIfMainThread("JSPlaybackTracker.constructor");
|
||||
if(!obj.has("onProgress"))
|
||||
throw ScriptImplementationException(config, "Missing onProgress on PlaybackTracker");
|
||||
if(!obj.has("nextRequest"))
|
||||
@@ -31,6 +33,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
}
|
||||
|
||||
override fun onInit(seconds: Double) {
|
||||
warnIfMainThread("JSPlaybackTracker.onInit");
|
||||
synchronized(_obj) {
|
||||
if(_hasCalledInit)
|
||||
return;
|
||||
@@ -44,6 +47,7 @@ class JSPlaybackTracker: IPlaybackTracker {
|
||||
}
|
||||
|
||||
override fun onProgress(seconds: Double, isPlaying: Boolean) {
|
||||
warnIfMainThread("JSPlaybackTracker.onProgress");
|
||||
synchronized(_obj) {
|
||||
if(!_hasCalledInit && _hasInit)
|
||||
onInit(seconds);
|
||||
|
||||
+2
-2
@@ -137,11 +137,11 @@ abstract class MultiParallelPager<T> : IPager<T>, IAsyncPager<T> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Pager prepare in ${timeForPage}ms");
|
||||
Logger.v(TAG, "Pager prepare in ${timeForPage}ms");
|
||||
val timeAwait = measureTimeMillis {
|
||||
_currentResults = results.map { it.await() }.mapNotNull { it };
|
||||
};
|
||||
Logger.i(TAG, "Pager load in ${timeAwait}ms");
|
||||
Logger.v(TAG, "Pager load in ${timeAwait}ms");
|
||||
|
||||
_currentResultExceptions = exceptions;
|
||||
return _currentResults;
|
||||
|
||||
@@ -111,7 +111,7 @@ class ChannelContentCache {
|
||||
init {
|
||||
val results = pager.getResults();
|
||||
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results");
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
|
||||
@@ -64,7 +64,7 @@ class StateCasting {
|
||||
}
|
||||
|
||||
override fun serviceResolved(event: ServiceEvent) {
|
||||
Logger.i(TAG, "ChromeCast service resolved: " + event.info);
|
||||
Logger.v(TAG, "ChromeCast service resolved: " + event.info);
|
||||
addOrUpdateDevice(event);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.futo.platformplayer.constructs
|
||||
|
||||
import android.provider.Settings.Global
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@@ -39,8 +40,7 @@ class BatchedTaskHandler<TParameter, TResult> {
|
||||
|
||||
//Cached
|
||||
if(result != null)
|
||||
//TODO: Replace with some kind of constant Deferred<IPlatformStreamVideo>
|
||||
return _scope.async { result as TResult }
|
||||
return CompletableDeferred(result as TResult);
|
||||
//Already requesting
|
||||
if(taskResult != null)
|
||||
return taskResult as Deferred<TResult>;
|
||||
|
||||
@@ -91,7 +91,7 @@ class TaskHandler<TParameter, TResult> {
|
||||
if (!onError.emit(e, parameter)) {
|
||||
Logger.e(TAG, "Uncaught exception handled by TaskHandler.", e);
|
||||
} else {
|
||||
Logger.w(TAG, "Handled exception in TaskHandler invoke.", e);
|
||||
//Logger.w(TAG, "Handled exception in TaskHandler invoke.", e); (Prevents duplicate logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateBackup
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -58,13 +59,13 @@ class AutomaticBackupDialog(context: Context) : AlertDialog(context) {
|
||||
}
|
||||
clearFocus();
|
||||
dismiss();
|
||||
|
||||
Logger.i(TAG, "Set AutoBackupPassword");
|
||||
Settings.instance.backup.autoBackupPassword = _editPassword.text.toString();
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
|
||||
UIDialogs.toast(context, "AutoBackup enabled");
|
||||
|
||||
try {
|
||||
StateBackup.startAutomaticBackup(true);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
|
||||
val processHandle = StatePolycentric.instance.processHandle!!
|
||||
val eventPointer = processHandle.post(comment, null, ref)
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers()
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.futo.platformplayer.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Looper
|
||||
import com.caoccao.javet.exceptions.JavetCompilationException
|
||||
import com.caoccao.javet.exceptions.JavetExecutionException
|
||||
import com.caoccao.javet.interop.V8Host
|
||||
@@ -17,6 +18,7 @@ import com.futo.platformplayer.engine.exceptions.*
|
||||
import com.futo.platformplayer.engine.internal.V8Converter
|
||||
import com.futo.platformplayer.engine.packages.*
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StateAssets
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
@@ -25,6 +27,7 @@ class V8Plugin {
|
||||
private val _client: ManagedHttpClient;
|
||||
private val _clientAuth: ManagedHttpClient;
|
||||
|
||||
|
||||
val httpClient: ManagedHttpClient get() = _client;
|
||||
val httpClientAuth: ManagedHttpClient get() = _clientAuth;
|
||||
|
||||
@@ -137,6 +140,8 @@ class V8Plugin {
|
||||
return executeTyped<V8Value>(js);
|
||||
}
|
||||
fun <T : V8Value> executeTyped(js: String) : T {
|
||||
warnIfMainThread("V8Plugin.executeTyped");
|
||||
|
||||
val runtime = _runtime ?: throw IllegalStateException("JSPlugin not started yet");
|
||||
return catchScriptErrors("Plugin[${config.name}]", js) { runtime.getExecutor(js).execute() };
|
||||
}
|
||||
|
||||
+2
-2
@@ -84,8 +84,8 @@ class HomeFragment : MainFragment() {
|
||||
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
|
||||
|
||||
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
_announcementsView = AnnouncementView(context).apply {
|
||||
headerView.addView(AnnouncementView(context))
|
||||
_announcementsView = AnnouncementView(context, null).apply {
|
||||
headerView.addView(this);
|
||||
};
|
||||
|
||||
_taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({ fragment.lifecycleScope }, {
|
||||
|
||||
+1
-1
@@ -355,7 +355,7 @@ class PostDetailFragment : MainFragment {
|
||||
processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+2
-2
@@ -124,8 +124,8 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
if (announcementsView == null && !isHomeEnabled) {
|
||||
val c = context;
|
||||
if (c != null) {
|
||||
_announcementsView = AnnouncementView(c).apply {
|
||||
headerView?.addView(AnnouncementView(c))
|
||||
_announcementsView = AnnouncementView(c, null).apply {
|
||||
headerView?.addView(this)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -306,12 +306,12 @@ class VideoDetailFragment : MainFragment {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume();
|
||||
Logger.i(TAG, "onResume");
|
||||
Logger.v(TAG, "onResume");
|
||||
_isActive = true;
|
||||
_leavingPiP = false;
|
||||
|
||||
_viewDetail?.let {
|
||||
Logger.i(TAG, "onResume preventPictureInPicture=false");
|
||||
Logger.v(TAG, "onResume preventPictureInPicture=false");
|
||||
it.preventPictureInPicture = false;
|
||||
|
||||
if (state != State.CLOSED) {
|
||||
@@ -326,7 +326,7 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Logger.i(TAG, "onPause");
|
||||
Logger.v(TAG, "onPause");
|
||||
_isActive = false;
|
||||
|
||||
if(!isInPictureInPicture && state != State.CLOSED)
|
||||
@@ -334,7 +334,7 @@ class VideoDetailFragment : MainFragment {
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Logger.i(TAG, "onStop");
|
||||
Logger.v(TAG, "onStop");
|
||||
|
||||
stopIfRequired();
|
||||
super.onStop();
|
||||
@@ -352,7 +352,7 @@ class VideoDetailFragment : MainFragment {
|
||||
shouldStop = false;
|
||||
}
|
||||
|
||||
Logger.i(TAG, "shouldStop: $shouldStop");
|
||||
Logger.v(TAG, "shouldStop: $shouldStop");
|
||||
if(shouldStop) {
|
||||
_viewDetail?.let {
|
||||
val v = it.video ?: return@let;
|
||||
@@ -361,13 +361,13 @@ class VideoDetailFragment : MainFragment {
|
||||
|
||||
_viewDetail?.onStop();
|
||||
StateCasting.instance.onStop();
|
||||
Logger.i(TAG, "called onStop() shouldStop: $shouldStop");
|
||||
Logger.v(TAG, "called onStop() shouldStop: $shouldStop");
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
Logger.i(TAG, "onDestroyMainView");
|
||||
Logger.v(TAG, "onDestroyMainView");
|
||||
_viewDetail?.let {
|
||||
_viewDetail = null;
|
||||
it.onDestroy();
|
||||
|
||||
+82
-11
@@ -100,6 +100,7 @@ import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToLong
|
||||
import kotlin.streams.toList
|
||||
|
||||
@@ -232,9 +233,19 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
private val DP_5 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics);
|
||||
private val DP_2 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, resources.displayMetrics);
|
||||
|
||||
private var _retryJob: Job? = null;
|
||||
private var _retryCount = 0;
|
||||
private val _retryIntervals: Array<Long> = arrayOf(1, 2, 4, 8, 16, 32);
|
||||
//TODO: Determine better behavior, waiting 60 seconds for an error that is guaranteed to happen is a bit much. (Needed? If so, maybe need special UI for retrying)
|
||||
private val _retryIntervals: Array<Long> = arrayOf(1, 1);//2, 4, 8, 16, 32);
|
||||
|
||||
private var _liveTryJob: Job? = null;
|
||||
private val _liveStreamCheckInterval = listOf(
|
||||
Pair(-10 * 60, 5 * 60), //around 10 minutes, try every 5 minute
|
||||
Pair(-5 * 60, 30), //around 5 minutes, try every 30 seconds
|
||||
Pair(0, 10) //around live, try every 10 seconds
|
||||
);
|
||||
|
||||
|
||||
constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.fragview_video_detail, this);
|
||||
@@ -491,7 +502,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
MediaControlReceiver.onLowerVolumeReceived.subscribe(this) { handleLowerVolume() };
|
||||
MediaControlReceiver.onPlayReceived.subscribe(this) { handlePlay() };
|
||||
MediaControlReceiver.onPauseReceived.subscribe(this) { handlePause() };
|
||||
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo() };
|
||||
MediaControlReceiver.onNextReceived.subscribe(this) { nextVideo(true) };
|
||||
MediaControlReceiver.onPreviousReceived.subscribe(this) { prevVideo() };
|
||||
MediaControlReceiver.onCloseReceived.subscribe(this) {
|
||||
Logger.i(TAG, "MediaControlReceiver.onCloseReceived")
|
||||
@@ -660,7 +671,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
//Lifecycle
|
||||
fun onResume() {
|
||||
Logger.i(TAG, "onResume");
|
||||
Logger.v(TAG, "onResume");
|
||||
_onPauseCalled = false;
|
||||
|
||||
Logger.i(TAG, "_video: ${video?.name ?: "no video"}");
|
||||
@@ -694,7 +705,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_player.updateRotateLock();
|
||||
}
|
||||
fun onPause() {
|
||||
Logger.i(TAG, "onPause");
|
||||
Logger.v(TAG, "onPause");
|
||||
|
||||
_onPauseCalled = true;
|
||||
_taskLoadVideo.cancel();
|
||||
@@ -722,6 +733,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_overlay_quality_selector?.hide();
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_taskLoadVideo.cancel();
|
||||
handleStop();
|
||||
_didStop = true;
|
||||
@@ -808,6 +821,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_retryCount = 0;
|
||||
fetchVideo();
|
||||
|
||||
@@ -897,6 +912,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
_retryCount = 0;
|
||||
fetchVideo();
|
||||
}
|
||||
@@ -927,6 +944,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail;
|
||||
this.video = video;
|
||||
this._playbackTracker = null;
|
||||
|
||||
if(video is JSVideoDetails) {
|
||||
val me = this;
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -1001,6 +1019,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_subTitle.text = subTitleSegments.joinToString(" • ");
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (ref != null) {
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
@@ -1032,7 +1051,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
@@ -1085,12 +1104,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
_layoutRating.visibility = View.GONE;
|
||||
}
|
||||
|
||||
|
||||
//Overlay
|
||||
updateQualitySourcesOverlay(video);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
//Set Mediasource
|
||||
|
||||
val toResume = _videoResumePositionMilliseconds;
|
||||
_videoResumePositionMilliseconds = 0;
|
||||
loadCurrentVideo(toResume);
|
||||
@@ -1118,6 +1139,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_textResume.text = "";
|
||||
}
|
||||
|
||||
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
|
||||
@@ -1125,6 +1147,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
if(video.isLive && video.live != null) {
|
||||
loadLiveChat(video);
|
||||
}
|
||||
if(video.isLive && video.live == null && !video.video.videoSources.any())
|
||||
startLiveTry(video);
|
||||
|
||||
updateMoreButtons();
|
||||
}
|
||||
@@ -1254,7 +1278,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
//If LiveStream, set to end
|
||||
if(videoSource is IDashManifestSource || videoSource is IHLSManifestSource) {
|
||||
if (video?.isLive == true) {
|
||||
_player.seekToEnd(5000);
|
||||
_player.seekToEnd(6000);
|
||||
}
|
||||
|
||||
val videoTracks = _player.exoPlayer?.player?.currentTracks?.groups?.firstOrNull { it.mediaTrackGroup.type == C.TRACK_TYPE_VIDEO }
|
||||
@@ -1339,9 +1363,11 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
fun nextVideo(): Boolean {
|
||||
fun nextVideo(forceLoop: Boolean = false): Boolean {
|
||||
Logger.i(TAG, "nextVideo")
|
||||
val next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
|
||||
var next = StatePlayer.instance.nextQueueItem(_player.duration < 100 || (_player.position.toFloat() / _player.duration) < 0.9);
|
||||
if(next == null && forceLoop)
|
||||
next = StatePlayer.instance.restartQueue();
|
||||
if(next != null) {
|
||||
setVideoOverview(next);
|
||||
return true;
|
||||
@@ -1956,7 +1982,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
|
||||
fun setProgressBarOverlayed(isOverlayed: Boolean?) {
|
||||
Logger.i(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})");
|
||||
Logger.v(TAG, "setProgressBarOverlayed(isOverlayed: ${isOverlayed ?: "null"})");
|
||||
isOverlayed?.let{ _cast.setProgressBarOverlayed(it) };
|
||||
|
||||
if(isOverlayed == null) {
|
||||
@@ -2075,6 +2101,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryCount = 0;
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video (ScriptException)", it, ::fetchVideo);
|
||||
}
|
||||
}
|
||||
@@ -2085,6 +2113,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_retryCount = 0;
|
||||
_retryJob?.cancel();
|
||||
_retryJob = null;
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, "Failed to load video", it, ::fetchVideo);
|
||||
}
|
||||
} else TaskHandler(IPlatformVideoDetails::class.java, {fragment.lifecycleScope});
|
||||
@@ -2102,14 +2132,16 @@ class VideoDetailView : ConstraintLayout {
|
||||
Log.i(TAG, "handleErrorOrCall _retryCount=$_retryCount, starting retry job");
|
||||
|
||||
_retryJob?.cancel();
|
||||
_retryJob = StateApp.instance.scopeGetter().launch(Dispatchers.Main) {
|
||||
_retryJob = StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
delay(_retryIntervals[_retryCount++] * 1000);
|
||||
fetchVideo();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to fetch video.", e)
|
||||
Logger.e(TAG, "Failed to retry fetch video.", e)
|
||||
}
|
||||
}
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = null;
|
||||
} else if (isConnected && nextVideo()) {
|
||||
Log.i(TAG, "handleErrorOrCall retries failed, is connected, skipped to next video");
|
||||
} else {
|
||||
@@ -2118,6 +2150,45 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLiveTry(liveTryVideo: IPlatformVideoDetails) {
|
||||
val datetime = liveTryVideo.datetime ?: return;
|
||||
val diffSeconds = datetime.getNowDiffSeconds();
|
||||
val toWait = _liveStreamCheckInterval.toList().sortedBy { abs(diffSeconds - it.first) }.firstOrNull()?.second?.toLong() ?: return;
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main){
|
||||
UIDialogs.toast(context, "Not yet available, retrying in ${toWait}s");
|
||||
}
|
||||
|
||||
_liveTryJob?.cancel();
|
||||
_liveTryJob = fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
delay(toWait * 1000);
|
||||
val videoDetail = StatePlatform.instance.getContentDetails(liveTryVideo.url, true).await();
|
||||
if(videoDetail !is IPlatformVideoDetails)
|
||||
throw IllegalStateException("Expected media content, found ${video?.contentType}");
|
||||
|
||||
if(videoDetail.datetime != null && videoDetail.live == null && !videoDetail.video.videoSources.any()) {
|
||||
if(videoDetail.datetime!! > OffsetDateTime.now())
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Planned in ${videoDetail.datetime?.toHumanNowDiffString(true)}");
|
||||
}
|
||||
startLiveTry(liveTryVideo);
|
||||
}
|
||||
else
|
||||
withContext(Dispatchers.Main) {
|
||||
setVideoDetails(videoDetail);
|
||||
_liveTryJob = null;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to live try fetch video.", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to retry for live stream");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFragment(frag: VideoDetailFragment) {
|
||||
fragment = frag;
|
||||
fragment.onMinimize.subscribe {
|
||||
|
||||
@@ -14,6 +14,8 @@ class OrientationManager : OrientationEventListener {
|
||||
|
||||
constructor(context: Context) : super(context) { }
|
||||
|
||||
//TODO: Something weird is going on here
|
||||
//TODO: Old implementation felt pretty good for me, but now with 0 deadzone still feels bad, even though code should be identical?
|
||||
override fun onOrientationChanged(orientationAnglep: Int) {
|
||||
if (orientationAnglep == -1) return
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.futo.platformplayer.models
|
||||
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
class HistoryVideo {
|
||||
@@ -18,4 +23,41 @@ class HistoryVideo {
|
||||
this.position = position;
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
|
||||
fun toReconString(): String {
|
||||
return "${video.url}|||${date.toEpochSecond()}|||${position}|||${video.name}";
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromReconString(str: String, resolve: ((url: String)->SerializedPlatformVideo)? = null): HistoryVideo {
|
||||
var index = str.indexOf("|||");
|
||||
if(index < 0) throw IllegalArgumentException("Invalid history string: " + str);
|
||||
val url = str.substring(0, index);
|
||||
|
||||
var indexNext = str.indexOf("|||", index + 3);
|
||||
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
|
||||
val dateSec = str.substring(index + 3, indexNext).toLong();
|
||||
|
||||
index = indexNext;
|
||||
indexNext = str.indexOf("|||", index + 3);
|
||||
if(indexNext < 0) throw IllegalArgumentException("Invalid history string: " + str);
|
||||
val position = str.substring(index + 3, indexNext).toLong();
|
||||
val name = str.substring(indexNext + 3);
|
||||
|
||||
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
|
||||
id = PlatformID.asUrlID(url),
|
||||
name = name,
|
||||
thumbnails = Thumbnails(),
|
||||
author = PlatformAuthorLink(PlatformID.NONE, "Unknown", ""),
|
||||
datetime = null,
|
||||
url = url,
|
||||
shareUrl = url,
|
||||
duration = 0,
|
||||
viewCount = -1
|
||||
);
|
||||
|
||||
return HistoryVideo(video, position, OffsetDateTime.of(LocalDateTime.ofEpochSecond(dateSec, 0, ZoneOffset.UTC), ZoneOffset.UTC));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class MediaPlaybackService : Service() {
|
||||
private var _focusRequest: AudioFocusRequest? = null;
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Logger.i(TAG, "onStartCommand");
|
||||
Logger.v(TAG, "onStartCommand");
|
||||
|
||||
|
||||
if(!FragmentedStorage.isInitialized) {
|
||||
@@ -91,43 +91,49 @@ class MediaPlaybackService : Service() {
|
||||
_mediaSession?.setCallback(object: MediaSessionCompat.Callback() {
|
||||
override fun onSeekTo(pos: Long) {
|
||||
super.onSeekTo(pos)
|
||||
Log.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||
Logger.i(TAG, "Media session callback onSeekTo(pos = $pos)");
|
||||
MediaControlReceiver.onSeekToReceived.emit(pos);
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
super.onPlay();
|
||||
Log.i(TAG, "Media session callback onPlay()");
|
||||
Logger.i(TAG, "Media session callback onPlay()");
|
||||
MediaControlReceiver.onPlayReceived.emit();
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause();
|
||||
Log.i(TAG, "Media session callback onPause()");
|
||||
Logger.i(TAG, "Media session callback onPause()");
|
||||
MediaControlReceiver.onPauseReceived.emit();
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop();
|
||||
Log.i(TAG, "Media session callback onStop()");
|
||||
Logger.i(TAG, "Media session callback onStop()");
|
||||
MediaControlReceiver.onCloseReceived.emit();
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
super.onSkipToPrevious();
|
||||
Log.i(TAG, "Media session callback onSkipToPrevious()");
|
||||
Logger.i(TAG, "Media session callback onSkipToPrevious()");
|
||||
MediaControlReceiver.onPreviousReceived.emit();
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
super.onSkipToNext()
|
||||
Logger.i(TAG, "Media session callback onSkipToNext()");
|
||||
MediaControlReceiver.onNextReceived.emit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
Logger.i(TAG, "onCreate called");
|
||||
Logger.v(TAG, "onCreate");
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Logger.i(TAG, "onDestroy called");
|
||||
Logger.v(TAG, "onDestroy");
|
||||
_instance = null;
|
||||
MediaControlReceiver.onCloseReceived.emit();
|
||||
super.onDestroy();
|
||||
@@ -138,7 +144,7 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
fun closeMediaSession() {
|
||||
Logger.i(TAG, "closeMediaSession called");
|
||||
Logger.v(TAG, "closeMediaSession");
|
||||
stopForeground(true);
|
||||
|
||||
val focusRequest = _focusRequest;
|
||||
@@ -159,7 +165,7 @@ class MediaPlaybackService : Service() {
|
||||
}
|
||||
|
||||
fun updateMediaSession(videoUpdated: IPlatformVideo?) {
|
||||
Logger.i(TAG, "updateMediaSession called");
|
||||
Logger.v(TAG, "updateMediaSession");
|
||||
var isUpdating = false;
|
||||
val video: IPlatformVideo;
|
||||
if(videoUpdated == null) {
|
||||
@@ -270,7 +276,7 @@ class MediaPlaybackService : Service() {
|
||||
val notif = builder.build();
|
||||
notif.flags = notif.flags or NotificationCompat.FLAG_ONGOING_EVENT or NotificationCompat.FLAG_NO_CLEAR;
|
||||
|
||||
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "not null" else "null"} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
|
||||
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
|
||||
|
||||
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
|
||||
@@ -285,6 +291,7 @@ class MediaPlaybackService : Service() {
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
)
|
||||
.setState(state, pos, 1f, SystemClock.elapsedRealtime())
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.futo.platformplayer.states
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioManager
|
||||
@@ -10,12 +12,20 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.work.*
|
||||
import com.futo.platformplayer.*
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
@@ -42,7 +52,9 @@ import java.util.concurrent.TimeUnit
|
||||
class StateApp {
|
||||
val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active
|
||||
|
||||
/*
|
||||
private val externalRootDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "Grayjay");
|
||||
|
||||
fun getExternalRootDirectory(): File? {
|
||||
if(!externalRootDirectory.exists()) {
|
||||
val result = externalRootDirectory.mkdirs();
|
||||
@@ -52,6 +64,57 @@ class StateApp {
|
||||
}
|
||||
else
|
||||
return externalRootDirectory;
|
||||
}*/
|
||||
|
||||
fun getExternalGeneralDirectory(context: Context): DocumentFile? {
|
||||
val generalUri = Settings.instance.storage.getStorageGeneralUri();
|
||||
if(isValidStorageUri(context, generalUri))
|
||||
return DocumentFile.fromTreeUri(context, generalUri!!);
|
||||
return null;
|
||||
}
|
||||
fun changeExternalGeneralDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
|
||||
if(context is Context)
|
||||
requestDirectoryAccess(context, "General Files", "This directory is used to save auto-backups and other persistent files.", null) {
|
||||
if(it != null)
|
||||
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
|
||||
if(it != null && isValidStorageUri(context, it)) {
|
||||
Logger.i(TAG, "Changed external general directory: ${it}");
|
||||
Settings.instance.storage.storage_general = it.toString();
|
||||
Settings.instance.save();
|
||||
|
||||
onChanged?.invoke(getExternalGeneralDirectory(context));
|
||||
}
|
||||
else
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("Failed to gain access to\n [${it?.lastPathSegment}]");
|
||||
};
|
||||
};
|
||||
}
|
||||
fun getExternalDownloadDirectory(context: Context): DocumentFile? {
|
||||
val downloadUri = Settings.instance.storage.storage_download?.let { Uri.parse(it) };
|
||||
if(isValidStorageUri(context, downloadUri))
|
||||
return DocumentFile.fromTreeUri(context, downloadUri!!);
|
||||
return null;
|
||||
}
|
||||
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
|
||||
if(context is Context)
|
||||
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
|
||||
if(it != null)
|
||||
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION.or(Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)));
|
||||
if(it != null && isValidStorageUri(context, it)) {
|
||||
Logger.i(TAG, "Changed external download directory: ${it}");
|
||||
Settings.instance.storage.storage_general = it.toString();
|
||||
Settings.instance.save();
|
||||
|
||||
onChanged?.invoke(getExternalDownloadDirectory(context));
|
||||
}
|
||||
};
|
||||
}
|
||||
fun isValidStorageUri(context: Context, uri: Uri?): Boolean {
|
||||
if(uri == null)
|
||||
return false;
|
||||
|
||||
return context.contentResolver.persistedUriPermissions.any { it.uri == uri && it.isReadPermission && it.isWritePermission };
|
||||
}
|
||||
|
||||
//Scope
|
||||
@@ -158,6 +221,32 @@ class StateApp {
|
||||
return state;
|
||||
}
|
||||
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
|
||||
{
|
||||
if(activity is Context)
|
||||
{
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Directory required for\n${name}", "Please select a directory for ${name}.\n${purpose}".trim(), null, 0,
|
||||
UIDialogs.Action("Cancel", {}),
|
||||
UIDialogs.Action("Ok", {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
if(path != null)
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
|
||||
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.or(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
activity.launchForResult(intent, 99) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
handle(it.data?.data);
|
||||
}
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||
};
|
||||
}, UIDialogs.ActionStyle.PRIMARY));
|
||||
}
|
||||
}
|
||||
|
||||
//Lifecycle
|
||||
fun setGlobalContext(context: Context, coroutineScope: CoroutineScope? = null) {
|
||||
_context = context;
|
||||
@@ -260,7 +349,7 @@ class StateApp {
|
||||
}
|
||||
|
||||
Logger.onLogSubmitted.subscribe {
|
||||
scopeGetter().launch(Dispatchers.Main) {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
if (it != null) {
|
||||
UIDialogs.toast("Uploaded " + (it ?: "null"), true);
|
||||
@@ -338,16 +427,32 @@ class StateApp {
|
||||
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
|
||||
scheduleBackgroundWork(context, interval != 0, interval);
|
||||
|
||||
|
||||
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
|
||||
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
UIDialogs.toast("Missing general directory");
|
||||
changeExternalGeneralDirectory(context) {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
};
|
||||
}
|
||||
else {
|
||||
UIDialogs.showAutomaticBackupDialog(context);
|
||||
StateAnnouncement.instance.deleteAnnouncement("backup");
|
||||
}
|
||||
}, "No Backup", {
|
||||
Settings.instance.backup.didAskAutoBackup = true;
|
||||
Settings.instance.save();
|
||||
});
|
||||
}
|
||||
else if(Settings.instance.backup.didAskAutoBackup && Settings.instance.backup.shouldAutomaticBackup() && !Settings.instance.storage.isStorageMainValid(context)) {
|
||||
if(context is IWithResultLauncher) {
|
||||
Logger.i(TAG, "Backup set without general directory, please select general external directory");
|
||||
changeExternalGeneralDirectory(context) {
|
||||
Logger.i(TAG, "Directory set, Auto-backup should resume to this location");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -471,7 +576,6 @@ class StateApp {
|
||||
if (_lastNetworkState != NetworkState.DISCONNECTED) {
|
||||
scopeOrNull?.launch(Dispatchers.Main) {
|
||||
try {
|
||||
Logger.i(TAG, "onConnectionAvailable emitted");
|
||||
onConnectionAvailable.emit();
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to emit onConnectionAvailable", e)
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
package com.futo.platformplayer.states
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract.EXTRA_INITIAL_URI
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.copyTo
|
||||
import com.futo.platformplayer.copyToOutputStream
|
||||
import com.futo.platformplayer.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.getInputStream
|
||||
import com.futo.platformplayer.getNowDiffHours
|
||||
import com.futo.platformplayer.getOutputStream
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.readBytes
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.writeBytes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,6 +38,9 @@ import kotlinx.serialization.json.Json
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
@@ -34,12 +53,22 @@ class StateBackup {
|
||||
|
||||
private val _autoBackupLock = Object();
|
||||
|
||||
private fun getAutomaticBackupDocumentFiles(context: Context, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
return Pair(null, null);
|
||||
val uri = Settings.instance.storage.getStorageGeneralUri() ?: return Pair(null, null);
|
||||
val dir = DocumentFile.fromTreeUri(context, uri) ?: return Pair(null, null);
|
||||
val mainBackupFile = dir.findFile("GrayjayBackup.ezip") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip") else null;
|
||||
val secondaryBackupFile = dir.findFile("GrayjayBackup.ezip.old") ?: if(create) dir.createFile("grayjay/ezip", "GrayjayBackup.ezip.old") else null;
|
||||
return Pair(mainBackupFile, secondaryBackupFile);
|
||||
}
|
||||
/*
|
||||
private fun getAutomaticBackupFiles(): Pair<File, File> {
|
||||
val dir = StateApp.instance.getExternalRootDirectory();
|
||||
if(dir == null)
|
||||
throw IllegalStateException("Can't access external files");
|
||||
return Pair(File(dir, "GrayjayBackup.ezip"), File(dir, "GrayjayBackup.ezip.old"))
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
fun getAllMigrationStores(): List<ManagedStore<*>> = listOf(
|
||||
@@ -56,10 +85,11 @@ class StateBackup {
|
||||
return password.padStart(32, '9');
|
||||
}
|
||||
fun hasAutomaticBackup(): Boolean {
|
||||
if(StateApp.instance.getExternalRootDirectory() == null)
|
||||
val context = StateApp.instance.contextOrNull ?: return false;
|
||||
if(!Settings.instance.storage.isStorageMainValid(context))
|
||||
return false;
|
||||
val files = getAutomaticBackupFiles();
|
||||
return files.first.exists() || files.second.exists();
|
||||
val files = getAutomaticBackupDocumentFiles(context,);
|
||||
return files.first?.exists() ?: false || files.second?.exists() ?: false;
|
||||
}
|
||||
fun startAutomaticBackup(force: Boolean = false) {
|
||||
val lastBackupHoursAgo = Settings.instance.backup.lastAutoBackupTime.getNowDiffHours();
|
||||
@@ -72,20 +102,27 @@ class StateBackup {
|
||||
try {
|
||||
Logger.i(TAG, "Starting AutoBackup (Last ${lastBackupHoursAgo} ago)");
|
||||
synchronized(_autoBackupLock) {
|
||||
val context = StateApp.instance.contextOrNull ?: return@synchronized;
|
||||
val data = export();
|
||||
val zip = data.asZip();
|
||||
|
||||
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
|
||||
|
||||
val backupFiles = getAutomaticBackupFiles();
|
||||
val exportFile = backupFiles.first;
|
||||
if (exportFile.exists())
|
||||
exportFile.copyTo(backupFiles.second, true);
|
||||
if(!Settings.instance.storage.isStorageMainValid(context)) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
|
||||
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
|
||||
}
|
||||
}
|
||||
else {
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(context, true);
|
||||
val exportFile = backupFiles.first;
|
||||
if (exportFile?.exists() == true && backupFiles.second != null)
|
||||
exportFile!!.copyTo(context, backupFiles.second!!);
|
||||
exportFile!!.writeBytes(context, encryptedZip);
|
||||
|
||||
exportFile.writeBytes(encryptedZip);
|
||||
|
||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||
Settings.instance.save();
|
||||
Settings.instance.backup.lastAutoBackupTime = OffsetDateTime.now(); //OffsetDateTime.now();
|
||||
Settings.instance.save();
|
||||
}
|
||||
}
|
||||
Logger.i(TAG, "Finished AutoBackup");
|
||||
}
|
||||
@@ -97,32 +134,51 @@ class StateBackup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This goes has recently changed to use DocumentFiles and DocumentTree, and might need additional checks/edgecases covered.
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||
if(ifExists && !hasAutomaticBackup()) {
|
||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Sadly on reinstalls of app this fails on file permissions.
|
||||
|
||||
Logger.i(TAG, "Starting AutoBackup restore");
|
||||
synchronized(_autoBackupLock) {
|
||||
|
||||
val backupFiles = getAutomaticBackupFiles();
|
||||
val backupFiles = getAutomaticBackupDocumentFiles(StateApp.instance.context, false);
|
||||
try {
|
||||
if (!backupFiles.first.exists())
|
||||
if (backupFiles.first?.exists() != true)
|
||||
throw IllegalStateException("Backup file does not exist");
|
||||
|
||||
val backupBytesEncrypted = backupFiles.first.readBytes();
|
||||
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
|
||||
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
|
||||
importZipBytes(context, scope, backupBytes);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
} catch (ex: Throwable) {
|
||||
}
|
||||
catch (exSec: FileNotFoundException) {
|
||||
Logger.e(TAG, "Failed to access backup file", exSec);
|
||||
val activity = if(SettingsActivity.getActivity() != null)
|
||||
SettingsActivity.getActivity();
|
||||
else if(StateApp.instance.isMainActive)
|
||||
StateApp.instance.contextOrNull;
|
||||
else null;
|
||||
if(activity != null) {
|
||||
if(activity is IWithResultLauncher)
|
||||
StateApp.instance.requestDirectoryAccess(activity, "Grayjay Backup Directory", "Allows restoring of a backup", backupFiles.first?.parentFile?.uri) {
|
||||
if(it != null) {
|
||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity);
|
||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
||||
restoreAutomaticBackup(context, scope, password, ifExists);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
||||
if (!backupFiles.second.exists())
|
||||
if (backupFiles.second?.exists() != true)
|
||||
throw ex;
|
||||
|
||||
val backupBytesEncrypted = backupFiles.second.readBytes();
|
||||
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
|
||||
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password));
|
||||
importZipBytes(context, scope, backupBytes);
|
||||
Logger.i(TAG, "Finished AutoBackup restore");
|
||||
@@ -156,7 +212,8 @@ class StateBackup {
|
||||
);
|
||||
val storesToSave = getAllMigrationStores()
|
||||
.associateBy { it.name }
|
||||
.mapValues { it.value.getAllReconstructionStrings() };
|
||||
.mapValues { it.value.getAllReconstructionStrings() }
|
||||
.toMutableMap();
|
||||
val settings = Settings.instance.encode();
|
||||
val pluginSettings = StatePlugins.instance.getPlugins()
|
||||
.associateBy { it.config.id }
|
||||
@@ -166,7 +223,12 @@ class StateBackup {
|
||||
.associateBy { it.config.id }
|
||||
.mapValues { it.value.config.sourceUrl!! };
|
||||
|
||||
return ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
|
||||
|
||||
val export = ExportStructure(exportInfo, settings, storesToSave, pluginUrls, pluginSettings);
|
||||
//export.videoCache = StatePlaylists.instance.getHistory()
|
||||
// .distinctBy { it.video.url }
|
||||
// .map { it.video };
|
||||
return export;
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +404,7 @@ class StateBackup {
|
||||
val plugins: Map<String, String>,
|
||||
val pluginSettings: Map<String, Map<String, String?>>,
|
||||
) {
|
||||
var videoCache: List<SerializedPlatformVideo>? = null;
|
||||
|
||||
fun asZip(): ByteArray {
|
||||
return ByteArrayOutputStream().use { byteStream ->
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.PlatformClientPool
|
||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.FilterGroup
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
@@ -30,6 +31,7 @@ import com.futo.platformplayer.awaitFirstNotNullDeferred
|
||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.fromPool
|
||||
import com.futo.platformplayer.getNowDiffDays
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -38,6 +40,7 @@ import com.futo.platformplayer.stores.*
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.internal.concat
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.reflect.jvm.internal.impl.builtins.jvm.JavaToKotlinClassMap.PlatformMutabilityMapping
|
||||
import kotlin.streams.toList
|
||||
|
||||
/***
|
||||
@@ -45,7 +48,7 @@ import kotlin.streams.toList
|
||||
*/
|
||||
class StatePlatform {
|
||||
private val TAG = "StatePlatform";
|
||||
private val VIDEO_CACHE = 1024 * 1024 * 10;
|
||||
private val VIDEO_CACHE = 100;
|
||||
|
||||
private val _scope = CoroutineScope(Dispatchers.IO);
|
||||
|
||||
@@ -61,8 +64,18 @@ class StatePlatform {
|
||||
private val _availableClients : ArrayList<IPlatformClient> = ArrayList();
|
||||
private val _enabledClients : ArrayList<IPlatformClient> = ArrayList();
|
||||
|
||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
private val _trackerClientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
//ClientPools are used to isolate plugin usage of certain components from others
|
||||
//This prevents for example a background task like subscriptions from blocking a user from opening a video
|
||||
//It also allows parallel usage of plugins that would otherwise be impossible.
|
||||
//Pools always follow the behavior of the base client. So if user disables a plugin, it kills all pooled clients.
|
||||
//Each pooled client adds additional memory usage.
|
||||
//WARNING: Be careful with pooling some calls, as they might use the plugin subsequently afterwards. For example pagers might block plugins in future calls.
|
||||
private val _mainClientPool = PlatformMultiClientPool("Main", 2); //Used for all main user events, generally user critical
|
||||
private val _pagerClientPool = PlatformMultiClientPool("Pagers", 2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
|
||||
private val _channelClientPool = PlatformMultiClientPool("Channels", 15); //Used primarily for subscription/background channel fetches
|
||||
private val _trackerClientPool = PlatformMultiClientPool("Trackers", 1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool("LiveEvents", 1); //Used exclusively for live events
|
||||
|
||||
|
||||
private val _primaryClientPersistent = FragmentedStorage.get<StringStorage>("primaryClient");
|
||||
private var _primaryClientObj : IPlatformClient? = null;
|
||||
@@ -84,14 +97,16 @@ class StatePlatform {
|
||||
private val _batchTaskGetVideoDetails: BatchedTaskHandler<String, IPlatformContentDetails> = BatchedTaskHandler<String, IPlatformContentDetails>(_scope,
|
||||
{ url ->
|
||||
Logger.i(StatePlatform::class.java.name, "Fetching video details [${url}]");
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.getContentDetails(url)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
_enabledClients.find { it.isContentDetailsUrl(url) }?.let {
|
||||
_mainClientPool.getClientPooled(it).getContentDetails(url)
|
||||
} ?: throw NoPlatformClientException("No client enabled that supports this url ($url)");
|
||||
},
|
||||
{
|
||||
if(!Settings.instance.browsing.videoCache)
|
||||
return@BatchedTaskHandler null;
|
||||
else {
|
||||
val cached = synchronized(_cache) { _cache.get(it); } ?: return@BatchedTaskHandler null;
|
||||
Logger.i(TAG, "Video Cache Hit [${cached.video.name}]");
|
||||
if (cached.creationTime.getNowDiffSeconds() > _cacheExpirationSeconds) {
|
||||
Logger.i(TAG, "Invalidated cache for [${it}]");
|
||||
synchronized(_cache) {
|
||||
@@ -232,36 +247,6 @@ class StatePlatform {
|
||||
fun getClient(id: String): IPlatformClient {
|
||||
return getClientOrNull(id) ?: throw IllegalArgumentException("Client with id $id does not exist");
|
||||
}
|
||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
|
||||
val pool = synchronized(_clientPools) {
|
||||
if(!_clientPools.containsKey(parentClient))
|
||||
_clientPools[parentClient] = PlatformClientPool(parentClient).apply {
|
||||
this.onDead.subscribe { client, pool ->
|
||||
synchronized(_clientPools) {
|
||||
if(_clientPools[parentClient] == pool)
|
||||
_clientPools.remove(parentClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
_clientPools[parentClient]!!;
|
||||
};
|
||||
return pool.getClient(capacity);
|
||||
}
|
||||
fun getTrackerClientPooled(parentClient: IPlatformClient, capacity: Int): IPlatformClient {
|
||||
val pool = synchronized(_trackerClientPools) {
|
||||
if(!_trackerClientPools.containsKey(parentClient))
|
||||
_trackerClientPools[parentClient] = PlatformClientPool(parentClient).apply {
|
||||
this.onDead.subscribe { client, pool ->
|
||||
synchronized(_trackerClientPools) {
|
||||
if(_trackerClientPools[parentClient] == pool)
|
||||
_trackerClientPools.remove(parentClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
_trackerClientPools[parentClient]!!;
|
||||
};
|
||||
return pool.getClient(capacity);
|
||||
}
|
||||
|
||||
fun getClientsByClaimType(claimType: Int): List<IPlatformClient> {
|
||||
return getEnabledClients().filter { it.isClaimTypeSupported(claimType) };
|
||||
@@ -390,7 +375,7 @@ class StatePlatform {
|
||||
synchronized(clientIdsOngoing) {
|
||||
clientIdsOngoing.add(it.id);
|
||||
}
|
||||
val homeResult = it.getHome();
|
||||
val homeResult = it.fromPool(_pagerClientPool).getHome();
|
||||
synchronized(clientIdsOngoing) {
|
||||
clientIdsOngoing.remove(it.id);
|
||||
}
|
||||
@@ -410,7 +395,7 @@ class StatePlatform {
|
||||
val deferred: List<Pair<IPlatformClient, Deferred<IPager<IPlatformContent>?>>> = clients.map {
|
||||
return@map Pair(it, scope.async(Dispatchers.IO) {
|
||||
try {
|
||||
val searchResult = it.getHome();
|
||||
val searchResult = it.fromPool(_pagerClientPool).getHome();
|
||||
return@async searchResult;
|
||||
} catch(ex: Throwable) {
|
||||
Logger.e(TAG, "getHomeRefresh", ex);
|
||||
@@ -626,7 +611,7 @@ class StatePlatform {
|
||||
if (baseClient !is JSClient) {
|
||||
return baseClient.getPlaybackTracker(url);
|
||||
}
|
||||
val client = getTrackerClientPooled(baseClient, 1);
|
||||
val client = _trackerClientPool.getClientPooled(baseClient, 1);
|
||||
return client.getPlaybackTracker(url);
|
||||
}
|
||||
|
||||
@@ -650,7 +635,7 @@ class StatePlatform {
|
||||
val clientCapabilities = baseClient.getChannelCapabilities();
|
||||
|
||||
val client = if(usePooledClients > 1)
|
||||
getClientPooled(baseClient, usePooledClients);
|
||||
_channelClientPool.getClientPooled(baseClient, usePooledClients);
|
||||
else baseClient;
|
||||
|
||||
var lastStream: OffsetDateTime? = null;
|
||||
@@ -801,7 +786,7 @@ class StatePlatform {
|
||||
if(!client.capabilities.hasGetComments)
|
||||
return EmptyPager();
|
||||
|
||||
return client.getComments(url);
|
||||
return client.fromPool(_mainClientPool).getComments(url);
|
||||
}
|
||||
fun getSubComments(comment: IPlatformComment): IPager<IPlatformComment> {
|
||||
Logger.i(TAG, "Platform - getSubComments");
|
||||
@@ -812,7 +797,7 @@ class StatePlatform {
|
||||
fun getLiveEvents(url: String): IPager<IPlatformLiveEvent>? {
|
||||
Logger.i(TAG, "Platform - getLiveChat");
|
||||
var client = getContentClient(url);
|
||||
return client.getLiveEvents(url);
|
||||
return client.fromPool(_liveEventClientPool).getLiveEvents(url);
|
||||
}
|
||||
fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? {
|
||||
Logger.i(TAG, "Platform - getLiveChat");
|
||||
|
||||
@@ -25,7 +25,7 @@ class StatePlayer {
|
||||
private val MIN_BUFFER_DURATION = 10000;
|
||||
private val MAX_BUFFER_DURATION = 60000;
|
||||
private val MIN_PLAYBACK_START_BUFFER = 500;
|
||||
private val MIN_PLAYBACK_RESUME_BUFFER = 1000;
|
||||
private val MIN_PLAYBACK_RESUME_BUFFER = 2500;
|
||||
private val BUFFER_SIZE = 1024 * 64;
|
||||
|
||||
var isOpen : Boolean = false
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
@@ -38,6 +39,11 @@ class StatePlaylists {
|
||||
})
|
||||
.load();
|
||||
private val _historyStore = FragmentedStorage.storeJson<HistoryVideo>("history")
|
||||
.withRestore(object: ReconstructStore<HistoryVideo>() {
|
||||
override fun toReconstruction(obj: HistoryVideo): String = obj.toReconString();
|
||||
override suspend fun toObject(id: String, backup: String, reconstructionBuilder: Builder): HistoryVideo
|
||||
= HistoryVideo.fromReconString(backup, null);
|
||||
})
|
||||
.load();
|
||||
val playlistStore = FragmentedStorage.storeJson<Playlist>("playlists")
|
||||
.withRestore(PlaylistBackup())
|
||||
@@ -49,7 +55,7 @@ class StatePlaylists {
|
||||
val onWatchLaterChanged = Event0();
|
||||
|
||||
fun toMigrateCheck(): List<ManagedStore<*>> {
|
||||
return listOf(playlistStore, _watchlistStore);
|
||||
return listOf(playlistStore, _watchlistStore, _historyStore);
|
||||
}
|
||||
|
||||
fun getWatchLater() : List<SerializedPlatformVideo> {
|
||||
@@ -122,6 +128,11 @@ class StatePlaylists {
|
||||
}
|
||||
|
||||
if (shouldUpdate) {
|
||||
|
||||
//A unrecovered item
|
||||
if(historyVideo.video.author.id.value == null && historyVideo.video.duration == 0L)
|
||||
historyVideo.video = SerializedPlatformVideo.fromVideo(video);
|
||||
|
||||
historyVideo.position = pos;
|
||||
historyVideo.date = OffsetDateTime.now();
|
||||
_historyStore.saveAsync(historyVideo);
|
||||
|
||||
@@ -27,12 +27,20 @@ class StatePlugins {
|
||||
private val TAG = "StatePlugins";
|
||||
|
||||
private val FORCE_REINSTALL_EMBEDDED = false;
|
||||
private var _isFirstEmbedUpdate = true;
|
||||
|
||||
private val _pluginScripts = FragmentedStorage.getDirectory<PluginScriptsDirectory>();
|
||||
private var _plugins = FragmentedStorage.storeJson<SourcePluginDescriptor>("plugins")
|
||||
.load();
|
||||
private val iconsDir = FragmentedStorage.getDirectory<PluginIconStorage>();
|
||||
|
||||
|
||||
private val _syncObject = Object()
|
||||
private var _embeddedSources: Map<String, String>? = null
|
||||
private var _embeddedSourcesDefault: List<String>? = null
|
||||
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
|
||||
|
||||
|
||||
fun getPluginIconOrNull(id: String): ImageVariable? {
|
||||
if(iconsDir.hasIcon(id))
|
||||
return iconsDir.getIconBinary(id);
|
||||
@@ -53,18 +61,6 @@ class StatePlugins {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class PluginConfig(
|
||||
val SOURCES_EMBEDDED: Map<String, String>,
|
||||
val SOURCES_EMBEDDED_DEFAULT: List<String>,
|
||||
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
|
||||
)
|
||||
|
||||
private val _syncObject = Object()
|
||||
private var _embeddedSources: Map<String, String>? = null
|
||||
private var _embeddedSourcesDefault: List<String>? = null
|
||||
private var _sourcesUnderConstruction: Map<String, ImageVariable>? = null
|
||||
|
||||
private fun ensureSourcesConfigLoaded(context: Context) {
|
||||
if (_embeddedSources != null && _embeddedSourcesDefault != null && _sourcesUnderConstruction != null) {
|
||||
return
|
||||
@@ -122,8 +118,11 @@ class StatePlugins {
|
||||
Logger.i(TAG, "Found outdated embedded plugin [${existing.config.id}] ${existing.config.name}, deleting and reinstalling");
|
||||
deletePlugin(embedded.key);
|
||||
}
|
||||
else if(existing != null && _isFirstEmbedUpdate)
|
||||
Logger.i(TAG, "Embedded plugin [${existing.config.id}] ${existing.config.name}, up to date (${existing.config.version} >= ${embeddedConfig?.version})");
|
||||
}
|
||||
}
|
||||
_isFirstEmbedUpdate = false;
|
||||
}
|
||||
fun installMissingEmbeddedPlugins(context: Context) {
|
||||
val plugins = getPlugins();
|
||||
@@ -422,6 +421,13 @@ class StatePlugins {
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
private data class PluginConfig(
|
||||
val SOURCES_EMBEDDED: Map<String, String>,
|
||||
val SOURCES_EMBEDDED_DEFAULT: List<String>,
|
||||
val SOURCES_UNDER_CONSTRUCTION: Map<String, String>
|
||||
)
|
||||
|
||||
companion object {
|
||||
private var _instance : StatePlugins? = null;
|
||||
val instance : StatePlugins
|
||||
|
||||
@@ -67,7 +67,7 @@ class StateSubscriptions {
|
||||
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
|
||||
}
|
||||
fun updateSubscriptionFeed(scope: CoroutineScope, onlyIfNull: Boolean = false, onProgress: ((Int, Int)->Unit)? = null) {
|
||||
Logger.i(TAG, "updateSubscriptionFeed");
|
||||
Logger.v(TAG, "updateSubscriptionFeed");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
synchronized(_globalSubscriptionsLock) {
|
||||
if (isGlobalUpdating || (onlyIfNull && _globalSubscriptionFeed != null)) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class CommentViewHolder : ViewHolder {
|
||||
processHandle.opinion(c.reference, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeGetter().launch(Dispatchers.IO) {
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
processHandle.fullyBackfillServers();
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -14,6 +16,9 @@ import com.futo.platformplayer.states.SessionAnnouncement
|
||||
import com.futo.platformplayer.states.StateAnnouncement
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AnnouncementView : LinearLayout {
|
||||
private val _root: ConstraintLayout;
|
||||
@@ -28,9 +33,13 @@ class AnnouncementView : LinearLayout {
|
||||
private val _category: String?;
|
||||
private var _currentAnnouncement: Announcement? = null;
|
||||
|
||||
private val _scope: CoroutineScope?;
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.view_announcement, this);
|
||||
|
||||
_scope = findViewTreeLifecycleOwner()?.lifecycleScope ?: StateApp.instance.scopeOrNull; //TODO: Fetch correct scope
|
||||
|
||||
val dp10 = 10.dp(resources);
|
||||
setPadding(dp10, dp10, dp10, dp10);
|
||||
|
||||
@@ -69,25 +78,23 @@ class AnnouncementView : LinearLayout {
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
Logger.i(TAG, "onAttachedToWindow");
|
||||
|
||||
super.onAttachedToWindow()
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||
refresh();
|
||||
_scope?.launch(Dispatchers.Main) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
Logger.i(TAG, "onDetachedFromWindow");
|
||||
|
||||
super.onDetachedFromWindow()
|
||||
StateAnnouncement.instance.onAnnouncementChanged.remove(this)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
Logger.i(TAG, "refresh");
|
||||
Logger.v(TAG, "refresh");
|
||||
val announcements = StateAnnouncement.instance.getVisibleAnnouncements(_category);
|
||||
setAnnouncement(announcements.firstOrNull(), announcements.size);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class GestureControlView : LinearLayout {
|
||||
} else {
|
||||
val rx = p0.x / width;
|
||||
val ry = p0.y / height;
|
||||
Logger.i(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
|
||||
Logger.v(TAG, "rx = $rx, ry = $ry, _isFullScreen = $_isFullScreen")
|
||||
if (ry > 0.1 && ry < 0.9) {
|
||||
if (_isFullScreen && rx < 0.4) {
|
||||
startAdjustingBrightness();
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.lang.reflect.Field
|
||||
@@ -33,6 +37,28 @@ class FieldForm : LinearLayout {
|
||||
_root = findViewById(R.id.field_form_root);
|
||||
}
|
||||
|
||||
fun fromObject(scope: CoroutineScope, obj : Any, onLoaded: (()->Unit)? = null) {
|
||||
_root.removeAllViews();
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
for (field in newFields) {
|
||||
if (field !is View)
|
||||
throw java.lang.IllegalStateException("Only views can be IFields");
|
||||
|
||||
_root.addView(field as View);
|
||||
field.onChanged.subscribe { a1, a2 ->
|
||||
onChanged.emit(a1, a2);
|
||||
};
|
||||
}
|
||||
_fields = newFields;
|
||||
|
||||
onLoaded?.invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
fun fromObject(obj : Any) {
|
||||
_root.removeAllViews();
|
||||
val newFields = getFieldsFromObject(context, obj);
|
||||
|
||||
@@ -328,7 +328,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Logger.i(TAG, "onPlaybackStateChanged $playbackState");
|
||||
Logger.v(TAG, "onPlaybackStateChanged $playbackState");
|
||||
val timeLeft = abs(position - duration);
|
||||
|
||||
if (playbackState == ExoPlayer.STATE_ENDED) {
|
||||
|
||||
@@ -130,9 +130,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
|
||||
Logger.i(TAG, "Attached onConnectionAvailable listener.");
|
||||
Logger.v(TAG, "Attached onConnectionAvailable listener.");
|
||||
StateApp.instance.onConnectionAvailable.subscribe(_referenceObject) {
|
||||
Logger.i(TAG, "onConnectionAvailable");
|
||||
Logger.v(TAG, "onConnectionAvailable");
|
||||
|
||||
val pos = position;
|
||||
val dur = duration;
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<com.futo.platformplayer.views.Loader
|
||||
android:id="@+id/loader"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:layout_marginTop="15dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp" />
|
||||
|
||||
<com.futo.platformplayer.views.fields.FieldForm
|
||||
android:id="@+id/settings_form"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<LinearLayout android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingStart="15dp"
|
||||
android:paddingEnd="15dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp" />
|
||||
android:layout_marginBottom="28dp">
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -11,7 +11,7 @@
|
||||
android:id="@+id/field_group_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="16dp"
|
||||
android:textSize="20dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/defaults"
|
||||
|
||||
@@ -303,6 +303,7 @@
|
||||
android:id="@+id/videodetail_channel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="7dp"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
@@ -311,21 +312,22 @@
|
||||
|
||||
<com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
android:id="@+id/creator_thumbnail"
|
||||
android:layout_width="27dp"
|
||||
android:layout_height="27dp" />
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/videodetail_channel_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingEnd="10dp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="-4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
tools:text="Channel Name" />
|
||||
|
||||
Submodule app/src/stable/assets/sources/youtube updated: 35ac3ba949...123960682a
Submodule app/src/unstable/assets/sources/youtube updated: 35ac3ba949...7581696172
@@ -0,0 +1,62 @@
|
||||
# Authentication
|
||||
Grayjay supports offering platform login for a plugin. This however comes with several security concerns that we attempt to alleviate partially.
|
||||
|
||||
The goal of the authentication system is to provide plugins the ability to make authenticated requests without directly exposing credentials and tokens to the plugin. This is done by keeping all this data on the app side, and never passing it to the plugin.
|
||||
|
||||
>:warning: **This is not bulletproof**
|
||||
>Depending on the platform, the plugin still has full access to making authenticated requests, including ones that may expose your account to danger (like changing settings). Or if a platform exposes values (insecurely) in the response data (not headers).
|
||||
>
|
||||
>You should always only login (and install for that matter) plugins you trust.
|
||||
|
||||
How to actually use the authenticated client is described in the Http package documentation (See [Package: Http](_blank)).
|
||||
This documentation will exclusively focus on configuring authentication and how it behaves.
|
||||
|
||||
## How it works
|
||||
The authentication system works by allowing plugins to provide a login url, and a set of required headers/cookies/urls. When the user tries to log in, it will open the provided login url in an in-app webbrowser. Once all requirements are met, it will close this webbrowser and save the required data encrypted to app storage.
|
||||
|
||||
These authentication configs are put in the plugin config under the ```authentication``` property.
|
||||
## Example
|
||||
Here is an example of such an authentication configuration:
|
||||
|
||||
```json
|
||||
"authentication": {
|
||||
"loginUrl": "https://platform.com/some/login/url",
|
||||
"completionUrl": "https://platform.com/some/required/page", //Optional
|
||||
"loginButton": ".someContainer div .someButton" //Optional
|
||||
"userAgent": "Some User Agent", //Optional
|
||||
"domainHeadersToFind": { //Optional
|
||||
".platform.com": ["authorization"],
|
||||
"subdomain.platform.com": ["someHeader"],
|
||||
".somerelatedplatform.com": ["someOtherHeader"],
|
||||
},
|
||||
"cookiesToFind": ["someCookieToFind", "someOtherCookieToFind"], //Optional
|
||||
//"cookiesExclOthers": false //Optional
|
||||
//"allowedDomains": ["platform.com", "subdomain.platform.com"] //Optional
|
||||
}
|
||||
```
|
||||
Most platforms will only need a single header or cookie to function, but for some you may need very specific cookies for specific subdomains.
|
||||
|
||||
| | Property | Usage |
|
||||
|--|--|--|
|
||||
| **Mandatory** | ```loginUrl``` | Used to set the initial url for the login browser. |
|
||||
| Optional | ```completionUrl``` | Can be used to set a url that needs to be visited before concluding login. |
|
||||
| Optional | ```loginButton``` | Can be used to trigger a html element by providing a query selector to a single html element. This button is then "clicked" after the page finishes loading. This supports full query selector including classes, ids, tags, and more advanced like :first-child. |
|
||||
| Optional | ```userAgent``` | Can be used to set the user-agent of the browser during login. |
|
||||
| Optional | ```domainHeadersToFind``` | Can be used to find headers for specific subdomains. |
|
||||
| Optional | ```cookiesToFind``` | Can be used to find specific cookies. |
|
||||
| Optional | ```cookiesExclOthers``` |Can be used in the niche scenario where all other cookies should be disgarded when authenticated request are used. This is rather uncommon. |
|
||||
| Optional | ```allowedDomains``` | Can be used to only fulfill the above requirements on the domains specified in this property, any other domains may be cancelled. (NOT USEFUL FOR MOST PLUGINS) |
|
||||
|
||||
|
||||
## Header Behavior
|
||||
Headers are exclusively applied to the domains they are retrieved from. A plugin CANNOT send a header to a domain that it is not related to.
|
||||
|
||||
>:warning: **Plugins can elevate a header to a parent domain**
|
||||
>However a plugin can elevate a header to a parent domain. Meaning that if a header is retrieved in a request to ```somedomain.platform.com```, by defining the header for ```.platform.com``` it will be send to all requests of to any ```platform.com``` domain. This might be required for some platforms.
|
||||
|
||||
## Cookie Behavior
|
||||
By default, when authentication requests are made, the authenticated client will behave similar to that of a normal browser. Meaning that if the server you are communicating with sets new cookies, the client will use those cookies instead. These new cookies are NOT saved to disk, meaning that whenever that plugin reloads the cookies will revert to those assigned at login.
|
||||
|
||||
This behavior can be modified by using custom http clients as described in the http package documentation.
|
||||
(See [Package: Http](_blank))
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Content Types
|
||||
This page will cover the various types of content that are supported, and how to present them to Grayjay.
|
||||
|
||||
While Grayjay is primarily used for video, it supports various types of video, audio, but also text, images, and articles. In the future more types of content support might be added!
|
||||
|
||||
Content can be presented as a feed object, or a detail object. Feed objects are objects you see inside feeds and overviews such as the Home and Subscription tabs. Generally detail objects have an accompanying overview object.
|
||||
|
||||
Feed items are often returned in pagers, the following are some plugin methods that expect a pager of feed items:
|
||||
```
|
||||
source.getHome()
|
||||
source.getChannelContents(...)
|
||||
```
|
||||
Content details are generally retrieved using
|
||||
```
|
||||
source.getContentDetails(url)
|
||||
```
|
||||
|
||||
Note that all detail objects can be considered feed objects, but not the other way around. When you return a detail object in places where feed object is expected, and the user tries to open said item in a detail view, the ```GetContentDetail``` call is skipped, and the item is immediately shown without loading details.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Feed Types
|
||||
Feed types represent content in a feed or overview page. Most feed types have both a thumbnail and preview visualization, where they are displayed slightly differently. The plugin is not aware of these differences though.
|
||||
|
||||
## PlatformContent
|
||||
All feed objects inherit PlatformContent, and always have the following properties:
|
||||
```kotlin
|
||||
class PlatformContent
|
||||
{
|
||||
id: PlatformID,
|
||||
name: String,
|
||||
thumbnails: ThumbNails,
|
||||
author: PlatformAuthorLink,
|
||||
datetime: Int, // (UnixTimeStamp)
|
||||
url: String
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
## PlatformVideo
|
||||
A feed object representing a video or audio.
|
||||
*Usage:*
|
||||
```javascript
|
||||
new PlatformVideo({
|
||||
id: new PlatformID("SomePlatformName", "SomeId", config.id),
|
||||
name: "Some Video Name",
|
||||
thumbnails: new Thumbnails([
|
||||
new Thumbnail("https://.../...", 720),
|
||||
new Thumbnail("https://.../...", 1080),
|
||||
]),
|
||||
author: new AuthorLink(
|
||||
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
|
||||
"SomeAuthorName",
|
||||
"https://platform.com/your/channel/url",
|
||||
"../url/to/thumbnail.png"),
|
||||
uploadDate: 1696880568,
|
||||
duration: 120,
|
||||
viewCount: 1234567,
|
||||
url: "https://platform.com/your/detail/url",
|
||||
isLive: false
|
||||
});
|
||||
```
|
||||
|
||||
## PlatformPost
|
||||
A feed object representing a community post with text, and optionally images.
|
||||
|
||||
*Usage:*
|
||||
```javascript
|
||||
new PlatformPost{
|
||||
id: new PlatformID(config.name, item?.id, config.id),
|
||||
name: item?.attributes?.title,
|
||||
author: getPlatformAuthorLink(item, context),
|
||||
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
|
||||
url: item?.attributes?.url,
|
||||
description: "Description of Post",
|
||||
images: ["../url/to/image1.png", "../url/to/image2.png"],
|
||||
thumbnails: new Thumbnails([
|
||||
new Thumbnail("https://.../...", 720),
|
||||
new Thumbnail("https://.../...", 1080),
|
||||
])
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## PlatformNestedMediaContent
|
||||
A feed object representing a link to a different item (often handled by a different plugin).
|
||||
|
||||
An example is a Patreon video, that links to an unlisted Youtube video. If no plugin exists to handle the content, it will be opened in an in-app browser.
|
||||
|
||||
A nested item consists of an detail url and optional metadata such as name, description, thumbnails, etc.
|
||||
*Usage:*
|
||||
```javascript
|
||||
new PlatformNestedMediaContent({
|
||||
id: new PlatformID("SomePlatformName", "SomeId", config.id),
|
||||
name: "Name of content link",
|
||||
author: new AuthorLink(
|
||||
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
|
||||
"SomeAuthorName",
|
||||
"https://platform.com/your/channel/url",
|
||||
"../url/to/thumbnail.png"),,
|
||||
datetime: 1696880568,
|
||||
url: item?.attributes?.url,
|
||||
contentUrl: "https://someplatform.com/detail/url",
|
||||
contentName: "OptionalName",
|
||||
contentDescription: "OptionalDescription",
|
||||
contentProvider: "OptionalPlatformName",
|
||||
contentThumbnails: new Thumbnails([
|
||||
new Thumbnail("https://.../...", 720),
|
||||
new Thumbnail("https://.../...", 1080),
|
||||
])
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Detail Types
|
||||
Detail types represent content on a detail page.
|
||||
|
||||
## PlatformVideoDetails
|
||||
|
||||
A detail object representing a video or audio. It inherits PlatformVideo.
|
||||
|
||||
|
||||
### Usage:
|
||||
```javascript
|
||||
new PlatformVideoDetails({
|
||||
id: new PlatformID("SomePlatformName", "SomeId", config.id),
|
||||
name: "Some Video Name",
|
||||
thumbnails: new Thumbnails([
|
||||
new Thumbnail("https://.../...", 720),
|
||||
new Thumbnail("https://.../...", 1080),
|
||||
]),
|
||||
author: new AuthorLink(
|
||||
new PlatformID("SomePlatformName", "SomeAuthorID", config.id),
|
||||
"SomeAuthorName",
|
||||
"https://platform.com/your/channel/url",
|
||||
"../url/to/thumbnail.png"),
|
||||
uploadDate: 1696880568,
|
||||
duration: 120,
|
||||
viewCount: 1234567,
|
||||
url: "https://platform.com/your/detail/url",
|
||||
isLive: false,
|
||||
|
||||
description: "Some description",
|
||||
video: new VideoSourceDescriptor([]), //See sources
|
||||
live: null,
|
||||
rating: new RatingLikes(123),
|
||||
subtitles: []
|
||||
});
|
||||
```
|
||||
### Live Streams
|
||||
If your video is live, the ```isLive``` property should be ```true```, and the ```live``` property should be set to a ```HLSSource```, ```DashSource```, or equivelant.
|
||||
|
||||
### UnMuxed and Audio-Only
|
||||
If your content is either audio-only (eg. music), or has seperate video/audio tracks, you want to use ```UnMuxedVideoDescriptor``` instead of ```VideoSourceDescriptor```:
|
||||
```javascript
|
||||
new UnMuxedVideoDescriptor(
|
||||
[videoSource1, videoSource2, ...],
|
||||
[audioSource1, audioSource2, ...]
|
||||
);
|
||||
```
|
||||
|
||||
### Sources
|
||||
Inside a VideoDescriptor you need to provide an array of sources.
|
||||
Below you can find several source types that Grayjay supports:
|
||||
|
||||
**Standard Url Video/Audio**
|
||||
These are videos available directly on a single url.
|
||||
```javascript
|
||||
new VideoUrlSource({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container: "video/mp4",
|
||||
codec: "avc1.4d401e",
|
||||
name: "1080p30 mp4",
|
||||
bitrate: 188103,
|
||||
duration: 250,
|
||||
url: "https://platform.com/some/video/url.mp4"
|
||||
});
|
||||
//For audio:
|
||||
new AudioUrlSource({
|
||||
container: "audio/mp4",
|
||||
codec: "mp4a.40.2",
|
||||
name: "mp4a.40.2",
|
||||
bitrate: 131294,
|
||||
duration: 250,
|
||||
url: "https://platform.com/some/video/url.mp4a",
|
||||
language: "Unknown"
|
||||
});
|
||||
```
|
||||
**Range Url Video/Audio**
|
||||
These are more complex url sources that require very specific range headers to function. They require correct initialization and index positions.
|
||||
These are converted to Dash manifests.
|
||||
|
||||
```javascript
|
||||
new VideoUrlRangeSource({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
container: "video/mp4",
|
||||
codec: "avc1.4d401e",
|
||||
name: "1080p30 mp4",
|
||||
bitrate: 188103,
|
||||
duration: 250,
|
||||
url: "https://platform.com/some/video/url.mp4",
|
||||
itagId: 1234, //Optional
|
||||
initStart: 0,
|
||||
initEnd: 219,
|
||||
indexStart: 220,
|
||||
indexEnd: 791
|
||||
});
|
||||
//For Audio
|
||||
new AudioUrlRangeSource({
|
||||
container: "audio/mp4",
|
||||
codec: "mp4a.40.2",
|
||||
name: "mp4a.40.2",
|
||||
bitrate: 131294,
|
||||
duration: 250,
|
||||
url: "https://platform.com/some/video/url.mp4a",
|
||||
language: "Unknown"
|
||||
itagId: 1234, //Optional
|
||||
initStart: 0,
|
||||
initEnd: 219,
|
||||
indexStart: 220,
|
||||
indexEnd: 791,
|
||||
audioChannels: 2
|
||||
});
|
||||
```
|
||||
|
||||
**HLSSource**
|
||||
These are sources that are described in a HLS Manifest.
|
||||
```javascript
|
||||
new HLSSource({
|
||||
name: "SomeName", //Optional
|
||||
duration: 250, //Optional
|
||||
url: "https://platform.com/some/hls/manifest.m3u8",
|
||||
priority: false, //Optional
|
||||
language: "Unknown" //Optional
|
||||
});
|
||||
```
|
||||
Generally, HLS sources deprioritized in Grayjay. However if your platform requires HLS sources to be prioritized, you set ```priority``` to ```true```.
|
||||
|
||||
**DashSource**
|
||||
These are sources that are described in a Dash Manifest.
|
||||
```javascript
|
||||
new DashSource({
|
||||
name: "SomeName", //Optional
|
||||
duration: 250, //Optional
|
||||
url: "https://platform.com/some/dash/manifest.mpd"
|
||||
});
|
||||
```
|
||||
|
||||
## PlatformPostDetails
|
||||
A detail object representing a text with optionally accompanying images. The text can be either raw text or html (and possibly in future markup).
|
||||
|
||||
### Usage:
|
||||
```javascript
|
||||
new PlatformPostDetails{
|
||||
id: new PlatformID(config.name, item?.id, config.id),
|
||||
name: item?.attributes?.title,
|
||||
author: getPlatformAuthorLink(item, context),
|
||||
datetime: (Date.parse(item?.attributes?.published_at) / 1000),
|
||||
url: item?.attributes?.url,
|
||||
description: "Description of Post",
|
||||
images: ["../url/to/image1.png", "../url/to/image2.png"],
|
||||
thumbnails: new Thumbnails([
|
||||
new Thumbnail("https://.../thumbnail1.png", 720),
|
||||
new Thumbnail("https://.../thumbnail2.png", 1080),
|
||||
]),
|
||||
rating: new RatingLikes(123),
|
||||
textType: Type.Text.Html/Raw/Markup,
|
||||
content: "Your post content in either raw, html, or in future markup."
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Pagers
|
||||
Within Grayjay there are several situations where Pagers are used to communicate multiple pages of data back to the app. Some examples are home feed, channel contents, comments, live events, etc.
|
||||
|
||||
All these pagers have exact same layout and usage, with only some very specific cases where additional functionality is exposed.
|
||||
|
||||
Some example of base pagers that exist:
|
||||
|
||||
**ContentPager** for feed objects
|
||||
**ChannelPager** for channels
|
||||
**PlaylistPager** for playlists
|
||||
**CommentPager** for comments
|
||||
|
||||
An example of a pager implementation is as follows:
|
||||
```javascript
|
||||
class MyPlatformContentPager extends ContentPager {
|
||||
constructor(someInfo) {
|
||||
super([], true); //Alternatively, pass first page results in []
|
||||
this.someInfo = someInfo;
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
const myNewResults = //Fetch your next page
|
||||
this.results = myNewResults;
|
||||
this.hasMore = true; //Or false if last page
|
||||
}
|
||||
}
|
||||
```
|
||||
You can also choose to return an entirely new pager object in nextPage, but this is **NOT RECOMMENDED** as it generates a new object for every page. But can be convenient in some recursive situations.
|
||||
```
|
||||
nextPage() {
|
||||
return new MyPlatformContentPager(...);
|
||||
}
|
||||
```
|
||||
In this case the new pager will replace the parent.
|
||||
|
||||
If you ever just want to return an empty pager without any results, you can choose to directly use the base pagers as follows:
|
||||
```
|
||||
return new ContentPager([], false);
|
||||
```
|
||||
Which effectively says *"First page is empty, and no next page"*.
|
||||
Reference in New Issue
Block a user