mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-17 13:32:38 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a7e477e9b | |||
| b1aae244de | |||
| 7ebd8f13c2 | |||
| 1768d73c01 | |||
| ebcb894011 |
@@ -16,7 +16,10 @@ import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
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 +54,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,6 +71,8 @@ fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
}
|
||||
|
||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||
|
||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -892,6 +897,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,13 +6,16 @@ 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 com.futo.platformplayer.*
|
||||
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;
|
||||
|
||||
@@ -78,6 +81,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")
|
||||
|
||||
@@ -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,38 @@
|
||||
package com.futo.platformplayer.api.media
|
||||
|
||||
class PlatformMultiClientPool {
|
||||
private val _maxCap: Int;
|
||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||
|
||||
private var _isFake = false;
|
||||
|
||||
constructor(maxCap: Int = -1) {
|
||||
_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).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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }, {
|
||||
|
||||
+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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -927,6 +927,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 +1002,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_subTitle.text = subTitleSegments.joinToString(" • ");
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (ref != null) {
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
@@ -1085,12 +1087,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
_layoutRating.visibility = View.GONE;
|
||||
}
|
||||
|
||||
|
||||
//Overlay
|
||||
updateQualitySourcesOverlay(video);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
//Set Mediasource
|
||||
|
||||
val toResume = _videoResumePositionMilliseconds;
|
||||
_videoResumePositionMilliseconds = 0;
|
||||
loadCurrentVideo(toResume);
|
||||
@@ -1118,6 +1122,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
_textResume.text = "";
|
||||
}
|
||||
|
||||
|
||||
StatePlayer.instance.startOrUpdateMediaSession(context, video);
|
||||
StatePlayer.instance.setCurrentlyPlaying(video);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -43,6 +53,9 @@ 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();
|
||||
@@ -158,6 +171,32 @@ class StateApp {
|
||||
return state;
|
||||
}
|
||||
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, path: Uri?, handle: (Uri?)->Unit)
|
||||
{
|
||||
if(activity is Context)
|
||||
{
|
||||
UIDialogs.showDialog(activity, R.drawable.ic_security, "Missing Access", "Please grant access to ${name}", 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
|
||||
.and(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.and(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
|
||||
.and(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;
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
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.encryption.EncryptionProvider
|
||||
import com.futo.platformplayer.getNowDiffHours
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
@@ -22,6 +32,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,6 +47,14 @@ class StateBackup {
|
||||
|
||||
private val _autoBackupLock = Object();
|
||||
|
||||
private fun getAutomaticBackupDocumentFiles(context: Context, root: Uri, create: Boolean = false): Pair<DocumentFile?, DocumentFile?> {
|
||||
val dir = DocumentFile.fromTreeUri(context, root);
|
||||
if(dir == null)
|
||||
throw IllegalStateException("Can't access external document files");
|
||||
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)
|
||||
@@ -97,7 +118,13 @@ class StateBackup {
|
||||
}
|
||||
}
|
||||
}
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false) {
|
||||
|
||||
//TODO: This contains a temporary workaround to make it semi-compatible with > Android 11. By mixing "File" and "DocumentFile" usage.
|
||||
//TODO: For now this is used to at least recover and gain temporary access to docs after losing access (due to permission lost after reinstall)
|
||||
//TODO: Should be replaced with a more re-usable system that leverages OPEN_DOCUMENT_TREE once, and somehow persist this content after uninstall
|
||||
//TODO: DocumentFiles are not compatible with normal files and require its own system.
|
||||
//TODO: Investigate persistence of DOCUMENT_TREE files after uninstall...
|
||||
fun restoreAutomaticBackup(context: Context, scope: CoroutineScope, password: String, ifExists: Boolean = false, withStream: InputStream? = null) {
|
||||
if(ifExists && !hasAutomaticBackup()) {
|
||||
Logger.i(TAG, "No AutoBackup exists, not restoring");
|
||||
return;
|
||||
@@ -110,14 +137,33 @@ class StateBackup {
|
||||
|
||||
val backupFiles = getAutomaticBackupFiles();
|
||||
try {
|
||||
if (!backupFiles.first.exists())
|
||||
if (!backupFiles.first.exists() && withStream == null)
|
||||
throw IllegalStateException("Backup file does not exist");
|
||||
|
||||
val backupBytesEncrypted = backupFiles.first.readBytes();
|
||||
val backupBytesEncrypted = if(withStream != null) withStream.readBytes() else backupFiles.first.readBytes();
|
||||
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", backupFiles.first.parent?.toUri()) {
|
||||
if(it != null) {
|
||||
val customFiles = StateBackup.getAutomaticBackupDocumentFiles(activity, it);
|
||||
if(customFiles.first != null && customFiles.first!!.isFile && customFiles.first!!.exists() && customFiles.first!!.canRead())
|
||||
restoreAutomaticBackup(context, scope, password, ifExists, activity.contentResolver.openInputStream(customFiles.first!!.uri));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (ex: Throwable) {
|
||||
Logger.e(TAG, "Failed main AutoBackup restore", ex)
|
||||
if (!backupFiles.second.exists())
|
||||
throw ex;
|
||||
@@ -156,7 +202,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 +213,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 +394,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(2); //Used for all main user events, generally user critical
|
||||
private val _pagerClientPool = PlatformMultiClientPool(2); //Used primarily for calls that result in front-end pagers, preventing them from blocking other calls.
|
||||
private val _channelClientPool = PlatformMultiClientPool(15); //Used primarily for subscription/background channel fetches
|
||||
private val _trackerClientPool = PlatformMultiClientPool(1); //Used exclusively for playback trackers
|
||||
private val _liveEventClientPool = PlatformMultiClientPool(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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -73,7 +82,9 @@ class AnnouncementView : LinearLayout {
|
||||
|
||||
super.onAttachedToWindow()
|
||||
StateAnnouncement.instance.onAnnouncementChanged.subscribe(this) {
|
||||
refresh();
|
||||
_scope?.launch(Dispatchers.Main) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
Submodule app/src/stable/assets/sources/youtube updated: 35ac3ba949...123960682a
Submodule app/src/unstable/assets/sources/youtube updated: 35ac3ba949...2061a75ec1
@@ -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