mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-22 07:45:20 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f4dd65e8 | |||
| 599b119e62 | |||
| 41176464db | |||
| dd0ad19fb9 | |||
| 430625d2fb | |||
| 796cd1a776 | |||
| baa26af0c0 | |||
| ea0c27936e | |||
| 4aade35d19 | |||
| 251a5701af | |||
| 2da3116111 | |||
| 4c82fa1a4a | |||
| 7eef6eece2 | |||
| 570f32e980 | |||
| 16a0351125 | |||
| 2fa9005806 | |||
| 25527997fa | |||
| 4655d8369d | |||
| aeaaace3a4 | |||
| e6997004ff | |||
| 5e1896b7f2 | |||
| 88ca90c13a | |||
| f8ee340499 | |||
| 93f5260e20 |
@@ -92,6 +92,26 @@
|
||||
<data android:host="*" />
|
||||
<data android:scheme="file" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:host="*" />
|
||||
<data android:scheme="content" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:host="*" />
|
||||
<data android:scheme="file" />
|
||||
|
||||
<data android:mimeType="application/zip" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
|
||||
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
|
||||
|
||||
return "${value} ${unit}";
|
||||
};
|
||||
fun Int.toHumanTimeIndicator(abs: Boolean = false) : String {
|
||||
var value = this;
|
||||
|
||||
var unit = "s";
|
||||
|
||||
if(abs) value = abs(value);
|
||||
if(value >= secondsInHour) {
|
||||
value = (this / secondsInHour).toInt();
|
||||
if(abs) value = abs(value);
|
||||
unit = "hr" + (if(value > 1) "s" else "");
|
||||
}
|
||||
else if(value >= secondsInMinute) {
|
||||
value = (this / secondsInMinute).toInt();
|
||||
if(abs) value = abs(value);
|
||||
unit = "min";
|
||||
}
|
||||
|
||||
return "${value}${unit}";
|
||||
}
|
||||
|
||||
fun Long.toHumanTime(isMs: Boolean): String {
|
||||
var scaler = 1;
|
||||
|
||||
@@ -86,6 +86,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@FormField(
|
||||
R.string.submit_feedback, FieldForm.BUTTON,
|
||||
R.string.give_feedback_on_the_application, -1
|
||||
@@ -104,7 +105,7 @@ class Settings : FragmentedStorageFileJson() {
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
@FormField(
|
||||
R.string.manage_tabs, FieldForm.BUTTON,
|
||||
@@ -201,6 +202,12 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun getSubscriptionsConcurrency() : Int {
|
||||
return threadIndexToCount(subscriptionConcurrency);
|
||||
}
|
||||
|
||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9)
|
||||
var showWatchMetrics: Boolean = false;
|
||||
|
||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10)
|
||||
var allowPlaytimeTracking: Boolean = true;
|
||||
}
|
||||
|
||||
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
|
||||
@@ -596,6 +603,23 @@ class Settings : FragmentedStorageFileJson() {
|
||||
fun export() {
|
||||
StateBackup.startExternalBackup();
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
|
||||
fun import() {
|
||||
val act = SettingsActivity.getActivity() ?: return;
|
||||
StateApp.instance.requestFileReadAccess(act, null) {
|
||||
if(it != null && it.exists()) {
|
||||
val name = it.name;
|
||||
val contents = it.readBytes(act);
|
||||
if(contents != null) {
|
||||
if(name != null && name.endsWith(".zip", true))
|
||||
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
@FormField(R.string.payment, FieldForm.GROUP, -1, 14)
|
||||
|
||||
@@ -2,14 +2,24 @@ package com.futo.platformplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.CookieManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||
import com.caoccao.javet.values.primitive.V8ValueString
|
||||
import com.futo.platformplayer.activities.SettingsActivity
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.engine.V8Plugin
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
@@ -28,6 +38,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.stream.IntStream.range
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
@@ -87,11 +99,23 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance()
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||
R.string.test_background_worker_description, 3)
|
||||
fun triggerBackgroundUpdate() {
|
||||
val act = SettingsActivity.getActivity()!!;
|
||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
||||
|
||||
val wm = WorkManager.getInstance(act);
|
||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||
.build();
|
||||
wm.enqueue(req);
|
||||
}
|
||||
|
||||
@Contextual
|
||||
@Transient
|
||||
@FormField(R.string.v8_benchmarks, FieldForm.GROUP,
|
||||
R.string.various_benchmarks_using_the_integrated_v8_engine, 3)
|
||||
R.string.various_benchmarks_using_the_integrated_v8_engine, 4)
|
||||
val v8Benchmarks: V8Benchmarks = V8Benchmarks();
|
||||
class V8Benchmarks {
|
||||
@FormField(
|
||||
@@ -139,7 +163,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
||||
|
||||
@FormField(
|
||||
R.string.test_v8_communication_speed, FieldForm.BUTTON,
|
||||
R.string.tests_v8_communication_speeds, 2
|
||||
R.string.tests_v8_communication_speeds, 4
|
||||
)
|
||||
fun testV8RunSpeeds() {
|
||||
var plugin: V8Plugin? = null;
|
||||
|
||||
@@ -82,6 +82,8 @@ class UISlideOverlays {
|
||||
|
||||
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
|
||||
|
||||
if(subscription.doNotifications)
|
||||
menu.selectOption(null, "notifications", true, true);
|
||||
if(subscription.doFetchLive)
|
||||
menu.selectOption(null, "fetchLive", true, true);
|
||||
if(subscription.doFetchStreams)
|
||||
@@ -92,7 +94,7 @@ class UISlideOverlays {
|
||||
menu.selectOption(null, "fetchPosts", true, true);
|
||||
|
||||
menu.onOK.subscribe {
|
||||
StateSubscriptions.instance.saveSubscription(subscription);
|
||||
subscription.save();
|
||||
menu.hide(true);
|
||||
};
|
||||
menu.onCancel.subscribe {
|
||||
|
||||
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
|
||||
}
|
||||
}
|
||||
|
||||
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
|
||||
fun String.isHttpUrl(): Boolean {
|
||||
return _regexUrl.matchEntire(this) != null;
|
||||
}
|
||||
|
||||
|
||||
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
|
||||
fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
|
||||
@@ -497,6 +497,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
};
|
||||
startActivity(intent);
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://video/")) {
|
||||
val videoUrl = targetData.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
}
|
||||
else if(targetData.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = targetData.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
"content" -> {
|
||||
if(!handleContent(targetData, intent.type)) {
|
||||
@@ -583,6 +591,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, data);
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
|
||||
return handleUnknownText(String(data));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleFile(file: String): Boolean {
|
||||
@@ -600,6 +611,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
|
||||
return true;
|
||||
}
|
||||
else if(file.lowercase().endsWith(".txt")) {
|
||||
return handleUnknownText(String(readSharedFile(file)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleReconstruction(recon: String) {
|
||||
@@ -625,6 +639,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
fun handleUnknownText(text: String): Boolean {
|
||||
try {
|
||||
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
|
||||
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
|
||||
navigate(_fragImportSubscriptions, lines);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, ex.message, ex);
|
||||
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
fun handleUnknownJson(name: String?, json: String): Boolean {
|
||||
|
||||
val context = this;
|
||||
@@ -745,6 +773,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
||||
StateSaved.instance.setVideoToOpenBlocking(null);
|
||||
}
|
||||
|
||||
inline fun <reified T> isFragmentActive(): Boolean {
|
||||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
|
||||
@@ -92,6 +92,19 @@ open class JSClient : IPlatformClient {
|
||||
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
|
||||
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
|
||||
|
||||
fun getSubscriptionRateLimit(): Int? {
|
||||
val pluginRateLimit = config.subscriptionRateLimit;
|
||||
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
|
||||
if(settingsRateLimit > 0) {
|
||||
if(pluginRateLimit != null)
|
||||
return settingsRateLimit.coerceAtMost(pluginRateLimit);
|
||||
else
|
||||
return settingsRateLimit;
|
||||
}
|
||||
else
|
||||
return pluginRateLimit;
|
||||
}
|
||||
|
||||
val onDisabled = Event1<JSClient>();
|
||||
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
|
||||
|
||||
@@ -571,7 +584,7 @@ open class JSClient : IPlatformClient {
|
||||
if(it.containsKey(claimType)) {
|
||||
val templates = it[claimType];
|
||||
if(templates != null)
|
||||
for(value in values.keys.sortedBy { it }) {
|
||||
for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
|
||||
if(templates.containsKey(value)) {
|
||||
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
|
||||
}
|
||||
|
||||
+3
-1
@@ -41,10 +41,12 @@ class SourcePluginConfig(
|
||||
val constants: HashMap<String, String> = hashMapOf(),
|
||||
|
||||
//TODO: These should be vals...but prob for serialization reasons cannot be changed.
|
||||
var platformUrl: String? = null,
|
||||
var subscriptionRateLimit: Int? = null,
|
||||
var enableInSearch: Boolean = true,
|
||||
var enableInHome: Boolean = true,
|
||||
var supportedClaimTypes: List<Int> = listOf()
|
||||
var supportedClaimTypes: List<Int> = listOf(),
|
||||
var primaryClaimFieldType: Int? = null
|
||||
) : IV8PluginConfig {
|
||||
|
||||
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
|
||||
|
||||
+24
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||
import com.futo.platformplayer.views.fields.DropdownFieldOptions
|
||||
import com.futo.platformplayer.views.fields.FieldForm
|
||||
import com.futo.platformplayer.views.fields.FormField
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -79,6 +80,29 @@ class SourcePluginDescriptor {
|
||||
var enableSearch: Boolean? = null;
|
||||
}
|
||||
|
||||
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
|
||||
var rateLimit = RateLimit();
|
||||
@Serializable
|
||||
class RateLimit {
|
||||
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
|
||||
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
|
||||
var rateLimitSubs: Int = 0;
|
||||
|
||||
fun getSubRateLimit(): Int {
|
||||
return when(rateLimitSubs) {
|
||||
0 -> -1
|
||||
1 -> 25
|
||||
2 -> 50
|
||||
3 -> 75
|
||||
4 -> 100
|
||||
5 -> 125
|
||||
6 -> 150
|
||||
7 -> 200
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun loadDefaults(config: SourcePluginConfig) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaSession2Service.MediaNotification
|
||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
@@ -11,8 +13,12 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -29,10 +35,10 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) :
|
||||
class BackgroundWorker(private val appContext: Context, private val workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
if(StateApp.instance.isMainActive) {
|
||||
if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
|
||||
Logger.i("BackgroundWorker", "CANCELLED");
|
||||
return Result.success();
|
||||
}
|
||||
@@ -86,9 +92,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
val newSubChanges = hashSetOf<Subscription>();
|
||||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||
withContext(Dispatchers.IO) {
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
|
||||
|
||||
synchronized(manager) {
|
||||
@@ -103,29 +110,46 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
synchronized(newSubChanges) {
|
||||
if(!newSubChanges.contains(sub)) {
|
||||
newSubChanges.add(sub);
|
||||
if(sub.doNotifications)
|
||||
if(sub.doNotifications && content.datetime?.let { it < now } == true)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
}
|
||||
newItems.add(content);
|
||||
}
|
||||
});
|
||||
|
||||
//Only for testing notifications
|
||||
val testNotifs = 0;
|
||||
if(contentNotifs.size == 0 && testNotifs > 0) {
|
||||
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
|
||||
.take(testNotifs).forEach {
|
||||
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
manager.cancel(12);
|
||||
|
||||
if(newItems.size > 0) {
|
||||
if(contentNotifs.size > 0) {
|
||||
try {
|
||||
val items = contentNotifs.take(5).toList()
|
||||
for(i in items.indices) {
|
||||
val contentNotif = items.get(i);
|
||||
manager.notify(13 + i, NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New video by [${contentNotif.first.channel.name}]")
|
||||
.setContentText("${contentNotif.second.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, contentNotif.second.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id).build());
|
||||
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||
else null;
|
||||
if(thumbnail != null)
|
||||
Glide.with(appContext).asBitmap()
|
||||
.load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
})
|
||||
else
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -140,4 +164,20 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id).build());*/
|
||||
}
|
||||
|
||||
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New by [${sub.channel.name}]")
|
||||
.setContentText("${content.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id);
|
||||
if(thumbnail != null) {
|
||||
//notifBuilder.setLargeIcon(thumbnail);
|
||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||
}
|
||||
manager.notify(id, notifBuilder.build());
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,44 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.toSafeFileName
|
||||
import com.futo.polycentric.core.toUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.toList
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ChannelContentCache {
|
||||
private val _targetCacheSize = 3000;
|
||||
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
|
||||
val _channelContents = HashMap(_channelCacheDir.listFiles()
|
||||
.filter { it.isDirectory }
|
||||
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load()) });
|
||||
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
|
||||
init {
|
||||
val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
|
||||
val initializeTime = measureTimeMillis {
|
||||
_channelContents = HashMap(allFiles
|
||||
.filter { it.isDirectory }
|
||||
.parallelStream().map {
|
||||
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load())
|
||||
}.toList().associate { it })
|
||||
}
|
||||
val minDays = OffsetDateTime.now().minusDays(10);
|
||||
val totalItems = _channelContents.map { it.value.count() }.sum();
|
||||
val toTrim = totalItems - _targetCacheSize;
|
||||
val trimmed: Int;
|
||||
if(toTrim > 0) {
|
||||
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
|
||||
.sortedBy { it.datetime!! }.take(toTrim);
|
||||
for(content in redundantContent)
|
||||
uncacheContent(content);
|
||||
trimmed = redundantContent.size;
|
||||
}
|
||||
else trimmed = 0;
|
||||
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
|
||||
}
|
||||
|
||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
@@ -38,7 +63,9 @@ class ChannelContentCache {
|
||||
return PlatformContentPager(items, Math.min(150, items.size));
|
||||
}
|
||||
fun getSubscriptionCachePager(): DedupContentPager {
|
||||
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||
val allUrls = subs.map {
|
||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
||||
if(!otherUrls.contains(it.channel.url))
|
||||
@@ -46,6 +73,7 @@ class ChannelContentCache {
|
||||
else
|
||||
return@map otherUrls;
|
||||
}.flatten().distinct();
|
||||
Logger.i(TAG, "Subscriptions CachePager compiling");
|
||||
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = _channelContents
|
||||
@@ -58,7 +86,11 @@ class ChannelContentCache {
|
||||
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
fun uncacheContent(content: SerializedPlatformContent) {
|
||||
val store = getContentStore(content);
|
||||
store?.delete(content);
|
||||
}
|
||||
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { cacheContent(it) };
|
||||
}
|
||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||
@@ -66,14 +98,14 @@ class ChannelContentCache {
|
||||
return false;
|
||||
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
val store = synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
if(channelStore == null) {
|
||||
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}");
|
||||
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, channelStore);
|
||||
val store = getContentStore(channelId).let {
|
||||
if(it == null) {
|
||||
Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
|
||||
val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, store);
|
||||
return@let store;
|
||||
}
|
||||
return@synchronized channelStore;
|
||||
else return@let it;
|
||||
}
|
||||
val serialized = SerializedPlatformContent.fromContent(content);
|
||||
val existing = store.findItems { it.url == content.url };
|
||||
@@ -88,6 +120,17 @@ class ChannelContentCache {
|
||||
return existing.isEmpty();
|
||||
}
|
||||
|
||||
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
return getContentStore(channelId);
|
||||
}
|
||||
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
|
||||
return synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
return@synchronized channelStore;
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ChannelCache";
|
||||
|
||||
@@ -95,10 +138,11 @@ class ChannelContentCache {
|
||||
private var _instance: ChannelContentCache? = null;
|
||||
val instance: ChannelContentCache get() {
|
||||
synchronized(_lock) {
|
||||
if(_instance == null)
|
||||
if(_instance == null) {
|
||||
_instance = ChannelContentCache();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
return _instance!!;
|
||||
}
|
||||
|
||||
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
@@ -114,7 +158,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -134,7 +178,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription results");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+9
-2
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -33,6 +34,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
@@ -74,9 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
||||
return@TaskHandler getContentPager(it);
|
||||
}).success {
|
||||
}).success { livePager ->
|
||||
setLoading(false);
|
||||
setPager(it);
|
||||
|
||||
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
||||
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
|
||||
else livePager;
|
||||
|
||||
setPager(pager);
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
|
||||
+15
-7
@@ -170,6 +170,10 @@ class ChannelFragment : MainFragment() {
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
}
|
||||
_buttonSubscribe.onUnSubscribed.subscribe {
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
}
|
||||
|
||||
_buttonSubscriptionSettings.setOnClickListener {
|
||||
@@ -382,14 +386,18 @@ class ChannelFragment : MainFragment() {
|
||||
});
|
||||
});
|
||||
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
}
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
|
||||
});
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buttonSubscribe.setSubscribeChannel(channel);
|
||||
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.isHttpUrl
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -143,7 +144,10 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
};
|
||||
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it, true);
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
setQuery(it, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -6,10 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Spinner
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.views.adapters.SubscriptionAdapter
|
||||
|
||||
class CreatorsFragment : MainFragment() {
|
||||
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _spinnerSortBy: Spinner? = null;
|
||||
private var _overlayContainer: FrameLayout? = null;
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.fragment_creators, container, false);
|
||||
|
||||
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
|
||||
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
|
||||
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
|
||||
|
||||
_overlayContainer = view.findViewById(R.id.overlay_container);
|
||||
val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby);
|
||||
spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also {
|
||||
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
|
||||
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_spinnerSortBy = null;
|
||||
_overlayContainer = null;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
var filteredNextPageCounter = 0;
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
@@ -141,7 +142,15 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
val filteredResults = filterResults(it);
|
||||
recyclerData.results.addAll(filteredResults);
|
||||
recyclerData.resultsUnfiltered.addAll(it);
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
if(filteredResults.isEmpty()) {
|
||||
filteredNextPageCounter++
|
||||
if(filteredNextPageCounter <= 4)
|
||||
loadNextPage()
|
||||
}
|
||||
else {
|
||||
filteredNextPageCounter = 0;
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
}
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
|
||||
|
||||
companion object {
|
||||
val TAG = "ImportSubscriptionsFragment";
|
||||
private const val MAXIMUM_BATCH_SIZE = 75;
|
||||
private const val MAXIMUM_BATCH_SIZE = 100;
|
||||
fun newInstance() = ImportSubscriptionsFragment().apply {}
|
||||
}
|
||||
}
|
||||
+20
-3
@@ -218,6 +218,13 @@ class SourceDetailFragment : MainFragment() {
|
||||
BigButtonGroup(c, context.getString(R.string.authentication),
|
||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||
logoutSource();
|
||||
},
|
||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||
logoutSource(false);
|
||||
}.apply {
|
||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -286,12 +293,22 @@ class SourceDetailFragment : MainFragment() {
|
||||
_sourceButtons.addView(group);
|
||||
}
|
||||
|
||||
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
|
||||
val advancedButtons = BigButtonGroup(c, "Advanced",
|
||||
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
|
||||
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
},
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -311,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
reloadSource(config.id);
|
||||
};
|
||||
}
|
||||
private fun logoutSource() {
|
||||
private fun logoutSource(clear: Boolean = true) {
|
||||
val config = _config ?: return;
|
||||
|
||||
StatePlugins.instance.setPluginAuth(config.id, null);
|
||||
@@ -319,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
|
||||
//TODO: Maybe add a dialog option..
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout) {
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
|
||||
+28
-12
@@ -87,6 +87,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
@SuppressLint("ViewConstructor")
|
||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
@@ -110,13 +111,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
|
||||
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
|
||||
setProgress(currentProgress.first, currentProgress.second);
|
||||
|
||||
if(recyclerData.loadedFeedStyle != feedStyle ||
|
||||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
|
||||
recyclerData.lastLoad = OffsetDateTime.now();
|
||||
loadResults();
|
||||
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
loadResults(false);
|
||||
else if(recyclerData.results.size == 0)
|
||||
loadCache();
|
||||
}
|
||||
|
||||
val announcementsView = _announcementsView;
|
||||
@@ -170,12 +176,13 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
|
||||
if(!_bypassRateLimit) {
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
|
||||
Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
|
||||
if(rateLimitPlugins.any())
|
||||
throw RateLimitException(rateLimitPlugins.map { it.key.id });
|
||||
}
|
||||
_bypassRateLimit = false;
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
|
||||
|
||||
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
|
||||
@@ -198,7 +205,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
context.getString(R.string.rate_limit_warning), context.getString(R.string.this_is_a_temporary_measure_to_prevent_people_from_hitting_rate_limit_until_we_have_better_support_for_lots_of_subscriptions) + context.getString(R.string.you_have_too_many_subscriptions_for_the_following_plugins),
|
||||
subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
|
||||
_bypassRateLimit = true;
|
||||
loadResults();
|
||||
loadResults(true);
|
||||
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
|
||||
UIDialogs.Action("OK", {
|
||||
finishRefreshLayoutLoader();
|
||||
@@ -210,7 +217,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
|
||||
if(it !is CancellationException)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
@@ -275,23 +282,32 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
loadResults(true);
|
||||
}
|
||||
|
||||
|
||||
private fun loadCache() {
|
||||
Logger.i(TAG, "Subscriptions load cache");
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscriptions show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
}
|
||||
private fun loadResults(withRefetch: Boolean = false) {
|
||||
setLoading(true);
|
||||
Logger.i(TAG, "Subscriptions load");
|
||||
if(recyclerData.results.size == 0) {
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscription show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
loadCache();
|
||||
} else {
|
||||
setTextCentered(null);
|
||||
}
|
||||
_taskGetPager.run(withRefetch);
|
||||
}
|
||||
|
||||
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
|
||||
super.onRestoreCachedData(cachedData);
|
||||
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
}
|
||||
private fun loadedResult(pager: IPager<IPlatformContent>) {
|
||||
Logger.i(TAG, "Subscriptions new pager loaded");
|
||||
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
+4
-1
@@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(it);
|
||||
} else {
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+94
-6
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.provider.Browser
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
@@ -23,7 +22,6 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -38,6 +36,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
@@ -65,6 +64,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||
import com.futo.platformplayer.states.*
|
||||
@@ -95,7 +95,6 @@ import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView
|
||||
import com.google.android.exoplayer2.ui.TimeBar
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
|
||||
import com.google.common.base.Stopwatch
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
@@ -173,6 +172,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _addCommentView: AddCommentView;
|
||||
private val _toggleCommentType: Toggle;
|
||||
|
||||
private val _layoutSkip: LinearLayout;
|
||||
private val _textSkip: TextView;
|
||||
private val _textResume: TextView;
|
||||
private val _layoutResume: LinearLayout;
|
||||
private var _jobHideResume: Job? = null;
|
||||
@@ -296,6 +297,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_addCommentView = findViewById(R.id.add_comment_view);
|
||||
_commentsList = findViewById(R.id.comments_list);
|
||||
|
||||
_layoutSkip = findViewById(R.id.layout_skip);
|
||||
_textSkip = findViewById(R.id.text_skip);
|
||||
_layoutResume = findViewById(R.id.layout_resume);
|
||||
_textResume = findViewById(R.id.text_resume);
|
||||
_layoutPlayerContainer = findViewById(R.id.layout_player_container);
|
||||
@@ -403,6 +406,21 @@ class VideoDetailView : ConstraintLayout {
|
||||
_cast.onSettingsClick.subscribe { showVideoSettings() };
|
||||
_player.onVideoSettings.subscribe { showVideoSettings() };
|
||||
_player.onToggleFullScreen.subscribe(::handleFullScreen);
|
||||
_player.onChapterChanged.subscribe { chapter, isScrub ->
|
||||
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
|
||||
_layoutSkip.visibility = GONE;
|
||||
|
||||
if(!isScrub) {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
}
|
||||
else if(chapter?.type == ChapterType.SKIP) {
|
||||
_player.seekTo(chapter.timeEnd.toLong() * 1000);
|
||||
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cast.onMinimizeClick.subscribe {
|
||||
_player.setFullScreen(false);
|
||||
onMinimize.emit();
|
||||
@@ -416,6 +434,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!_isCasting && !_didStop) {
|
||||
setLastPositionMilliseconds(position, true);
|
||||
}
|
||||
updatePlaybackTracking(position);
|
||||
};
|
||||
|
||||
_player.onVideoClicked.subscribe {
|
||||
@@ -475,8 +494,14 @@ class VideoDetailView : ConstraintLayout {
|
||||
updatePillButtonVisibilities();
|
||||
|
||||
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
|
||||
if (StateCasting.instance.activeDevice != null) {
|
||||
val activeDevice = StateCasting.instance.activeDevice;
|
||||
if (activeDevice != null) {
|
||||
handlePlayChanged(it);
|
||||
|
||||
val v = video;
|
||||
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
|
||||
nextVideo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -581,6 +606,68 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
_layoutSkip.setOnClickListener {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val _trackingUpdateTimeLock = Object();
|
||||
val _trackingUpdateInterval = 2500;
|
||||
var _trackingLastUpdateTime = System.currentTimeMillis();
|
||||
var _trackingLastPosition: Long = 0;
|
||||
var _trackingLastVideo: IPlatformVideoDetails? = null;
|
||||
var _trackingTotalWatched: Long = 0;
|
||||
var _trackingDidCountView: Boolean = false;
|
||||
var _trackingLastVideoSubscription: Subscription? = null;
|
||||
fun updatePlaybackTracking(position: Long) {
|
||||
if(!Settings.instance.subscriptions.allowPlaytimeTracking)
|
||||
return;
|
||||
val now = System.currentTimeMillis();
|
||||
val shouldUpdate = synchronized(_trackingUpdateTimeLock) {
|
||||
val doUpdate = (now - _trackingLastUpdateTime) > _trackingUpdateInterval;
|
||||
if(doUpdate)
|
||||
_trackingLastUpdateTime = now;
|
||||
return@synchronized doUpdate;
|
||||
}
|
||||
if(shouldUpdate) {
|
||||
val currentVideo = video;
|
||||
val delta = position - _trackingLastPosition;
|
||||
_trackingLastPosition = position;
|
||||
|
||||
if(currentVideo != null && currentVideo == _trackingLastVideo) {
|
||||
if(delta > 500 && delta < _trackingUpdateInterval * 1.5) {
|
||||
_trackingLastVideoSubscription?.let {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] watch time delta [${delta}]" +
|
||||
"(${"%.2f".format((_trackingTotalWatched / 1000) / currentVideo.duration.toDouble().coerceAtLeast(1.0))})");
|
||||
it.updatePlayback(currentVideo, (delta / 1000).toInt());
|
||||
_trackingTotalWatched += delta;
|
||||
if(!_trackingDidCountView && currentVideo.duration > 0) {
|
||||
val percentage = (_trackingTotalWatched / 1000) / currentVideo.duration.toDouble();
|
||||
if(percentage > 0.4) {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] new view");
|
||||
_trackingDidCountView = true;
|
||||
it.addPlaybackView();
|
||||
}
|
||||
}
|
||||
it.saveAsync();
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(_trackingLastVideo == null && currentVideo == null)
|
||||
return;
|
||||
_trackingLastVideo = currentVideo;
|
||||
_trackingTotalWatched = 0;
|
||||
if(currentVideo?.author?.url != null)
|
||||
_trackingLastVideoSubscription = StateSubscriptions.instance.getSubscription(currentVideo.author.url);
|
||||
else
|
||||
_trackingLastVideoSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMoreButtons() {
|
||||
@@ -961,9 +1048,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to get chapters", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
/*withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
try {
|
||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||
|
||||
@@ -4,9 +4,12 @@ import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||
import com.futo.platformplayer.api.media.models.channels.SerializedChannel
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.getNowDiffDays
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -43,22 +46,41 @@ class Subscription {
|
||||
var uploadStreamInterval : Int = 0;
|
||||
var uploadPostInterval : Int = 0;
|
||||
|
||||
var playbackSeconds: Int = 0;
|
||||
var playbackViews: Int = 0;
|
||||
|
||||
|
||||
constructor(channel : SerializedChannel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
fun shouldFetchVideos() = true;
|
||||
fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7;
|
||||
fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14;
|
||||
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2;
|
||||
fun shouldFetchVideos() = doFetchVideos &&
|
||||
(lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
|
||||
(lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
|
||||
fun shouldFetchStreams() = doFetchStreams && (lastLiveStream.getNowDiffDays() < 7);
|
||||
fun shouldFetchLiveStreams() = doFetchLive && (lastLiveStream.getNowDiffDays() < 14);
|
||||
fun shouldFetchPosts() = doFetchPosts && (lastPost.getNowDiffDays() < 5);
|
||||
|
||||
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
|
||||
|
||||
fun save() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
fun saveAsync() {
|
||||
StateSubscriptions.instance.saveSubscription(this);
|
||||
}
|
||||
|
||||
fun updateChannel(channel: IPlatformChannel) {
|
||||
this.channel = SerializedChannel.fromChannel(channel);
|
||||
}
|
||||
|
||||
fun updatePlayback(content: IPlatformContentDetails, seconds: Int) {
|
||||
playbackSeconds += seconds;
|
||||
}
|
||||
fun addPlaybackView() {
|
||||
playbackViews += 1;
|
||||
}
|
||||
|
||||
fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) {
|
||||
val interval: Int;
|
||||
val mostRecent: OffsetDateTime?;
|
||||
@@ -84,30 +106,39 @@ class Subscription {
|
||||
else {
|
||||
interval = 5;
|
||||
mostRecent = null;
|
||||
Logger.i("Subscription", "Subscription [${channel.name}]:${type} no results found");
|
||||
}
|
||||
when(type) {
|
||||
ResultCapabilities.TYPE_VIDEOS -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_MIXED -> {
|
||||
uploadInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastVideo = mostRecent;
|
||||
else if(lastVideo.year > 3000)
|
||||
lastVideo = OffsetDateTime.MIN;
|
||||
lastVideoUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_STREAMS -> {
|
||||
uploadStreamInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastLiveStream = mostRecent;
|
||||
else if(lastLiveStream.year > 3000)
|
||||
lastLiveStream = OffsetDateTime.MIN;
|
||||
lastStreamUpdate = OffsetDateTime.now();
|
||||
}
|
||||
ResultCapabilities.TYPE_POSTS -> {
|
||||
uploadPostInterval = interval;
|
||||
if(mostRecent != null)
|
||||
lastPost = mostRecent;
|
||||
else if(lastPost.year > 3000)
|
||||
lastPost = OffsetDateTime.MIN;
|
||||
lastPostUpdate = OffsetDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
@@ -54,6 +55,8 @@ import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/***
|
||||
* This class contains global context for unconventional cases where obtaining context is hard.
|
||||
@@ -236,6 +239,25 @@ class StateApp {
|
||||
return state;
|
||||
}
|
||||
|
||||
fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, handle: (DocumentFile?)->Unit) {
|
||||
if(activity is Context) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
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);
|
||||
|
||||
activity.launchForResult(intent, 98) {
|
||||
if(it.resultCode == Activity.RESULT_OK) {
|
||||
val uri = it.data?.data;
|
||||
if(uri != null)
|
||||
handle(DocumentFile.fromSingleUri(activity, uri));
|
||||
}
|
||||
else
|
||||
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
|
||||
};
|
||||
}
|
||||
}
|
||||
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
|
||||
{
|
||||
if(activity is Context)
|
||||
@@ -380,6 +402,18 @@ class StateApp {
|
||||
fun mainAppStarted(context: Context) {
|
||||
Logger.i(TAG, "App started");
|
||||
|
||||
//Start loading cache
|
||||
instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val time = measureTimeMillis {
|
||||
ChannelContentCache.instance;
|
||||
}
|
||||
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to load announcements.", e)
|
||||
}
|
||||
}
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now())
|
||||
|
||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
|
||||
@@ -437,12 +471,13 @@ class StateApp {
|
||||
if(Settings.instance.subscriptions.fetchOnAppBoot) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n");
|
||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
||||
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
|
||||
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
|
||||
if (isRateLimitReached) {
|
||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||
delay(5000);
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
}
|
||||
else
|
||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||
|
||||
@@ -407,8 +407,9 @@ class StatePlatform {
|
||||
return@async searchResult;
|
||||
} catch(ex: Throwable) {
|
||||
Logger.e(TAG, "getHomeRefresh", ex);
|
||||
throw ex;
|
||||
//throw ex;
|
||||
//return@async null;
|
||||
return@async PlaceholderPager(10, { PlatformContentPlaceholder(it.id, ex) });
|
||||
}
|
||||
});
|
||||
}.toList();
|
||||
@@ -762,7 +763,7 @@ class StatePlatform {
|
||||
}
|
||||
|
||||
if(hasChanges)
|
||||
StateSubscriptions.instance.saveSubscription(sub);
|
||||
sub.save();
|
||||
}
|
||||
|
||||
return pagerResult;
|
||||
|
||||
@@ -108,14 +108,12 @@ class StatePlugins {
|
||||
instance.deletePlugin(embedded.key);
|
||||
StatePlatform.instance.updateAvailableClients(context);
|
||||
}
|
||||
fun updateEmbeddedPlugins(context: Context) {
|
||||
for(embedded in getEmbeddedSources(context)) {
|
||||
fun updateEmbeddedPlugins(context: Context, subset: List<String>? = null, force: Boolean = false) {
|
||||
for(embedded in getEmbeddedSources(context).filter { subset == null || subset.contains(it.key) }) {
|
||||
val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value);
|
||||
if(FORCE_REINSTALL_EMBEDDED)
|
||||
deletePlugin(embedded.key);
|
||||
else if(embeddedConfig != null) {
|
||||
if(embeddedConfig != null) {
|
||||
val existing = getPlugin(embedded.key);
|
||||
if(existing != null && existing.config.version < embeddedConfig.version ) {
|
||||
if(existing != null && (existing.config.version < embeddedConfig.version || (force || FORCE_REINSTALL_EMBEDDED))) {
|
||||
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
|
||||
//deletePlugin(embedded.key);
|
||||
installEmbeddedPlugin(context, embedded.value)
|
||||
@@ -376,7 +374,10 @@ class StatePlugins {
|
||||
if(icon != null)
|
||||
iconsDir.saveIconBinary(config.id, icon);
|
||||
|
||||
_plugins.save(SourcePluginDescriptor(config, existingAuth?.toEncrypted(), existingCaptcha?.toEncrypted(), flags));
|
||||
val descriptor = SourcePluginDescriptor(config, existingAuth?.toEncrypted(), existingCaptcha?.toEncrypted(), flags);
|
||||
descriptor.settings = existing?.settings ?: descriptor.settings;
|
||||
descriptor.appSettings = existing?.appSettings ?: descriptor.appSettings;
|
||||
_plugins.save(descriptor);
|
||||
return null;
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
|
||||
@@ -144,14 +144,15 @@ class StatePolycentric {
|
||||
return DedupContentPager(pager, StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun getChannelUrls(url: String, channelId: PlatformID? = null): List<String> {
|
||||
fun getChannelUrls(url: String, channelId: PlatformID? = null, cacheOnly: Boolean = false): List<String> {
|
||||
|
||||
var polycentricProfile: PolycentricProfile? = null;
|
||||
try {
|
||||
polycentricProfile = PolycentricCache.instance.getCachedProfile(url)?.profile;
|
||||
if (polycentricProfile == null && channelId != null) {
|
||||
Logger.i("StateSubscriptions", "Get polycentric profile not cached");
|
||||
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId) }?.profile;
|
||||
if(!cacheOnly)
|
||||
polycentricProfile = runBlocking { PolycentricCache.instance.getProfileAsync(channelId) }?.profile;
|
||||
} else {
|
||||
Logger.i("StateSubscriptions", "Get polycentric profile cached");
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
|
||||
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
|
||||
import kotlinx.coroutines.*
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.streams.toList
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
/***
|
||||
@@ -74,6 +76,13 @@ class StateSubscriptions {
|
||||
|
||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
||||
|
||||
fun getOldestUpdateTime(): OffsetDateTime {
|
||||
val subs = getSubscriptions();
|
||||
if(subs.size == 0)
|
||||
return OffsetDateTime.now();
|
||||
else
|
||||
return subs.minOf { it.lastVideoUpdate };
|
||||
}
|
||||
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
|
||||
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
|
||||
}
|
||||
@@ -170,6 +179,9 @@ class StateSubscriptions {
|
||||
fun saveSubscription(sub: Subscription) {
|
||||
_subscriptions.save(sub, false, true);
|
||||
}
|
||||
fun saveSubscriptionAsync(sub: Subscription) {
|
||||
_subscriptions.saveAsync(sub, false, true);
|
||||
}
|
||||
fun getSubscriptionCount(): Int {
|
||||
synchronized(_subscriptions) {
|
||||
return _subscriptions.getItems().size;
|
||||
@@ -229,7 +241,7 @@ class StateSubscriptions {
|
||||
|
||||
fun getSubscriptionRequestCount(): Map<JSClient, Int> {
|
||||
return SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, StateApp.instance.scope)
|
||||
.countRequests(getSubscriptions());
|
||||
.countRequests(getSubscriptions().associateWith { StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id, true) });
|
||||
}
|
||||
|
||||
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
|
||||
@@ -242,9 +254,13 @@ class StateSubscriptions {
|
||||
|
||||
}
|
||||
|
||||
val subUrls = getSubscriptions().associateWith {
|
||||
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id)
|
||||
};
|
||||
val usePolycentric = true;
|
||||
val subUrls = getSubscriptions().parallelStream().map {
|
||||
if(usePolycentric)
|
||||
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
|
||||
else
|
||||
Pair(it, listOf(it.channel.url));
|
||||
}.toList().associate { it };
|
||||
|
||||
val result = algo.getSubscriptions(subUrls);
|
||||
return Pair(result.pager, result.exceptions);
|
||||
|
||||
@@ -181,6 +181,12 @@ class ManagedStore<T>{
|
||||
return ReconstructionResult(successes, exs, builder.messages);
|
||||
}
|
||||
|
||||
|
||||
fun count(): Int {
|
||||
synchronized(_files) {
|
||||
return _files.size;
|
||||
}
|
||||
}
|
||||
fun getItems(): List<T> {
|
||||
synchronized(_files) {
|
||||
return _files.map { it.key };
|
||||
@@ -221,18 +227,18 @@ class ManagedStore<T>{
|
||||
}
|
||||
|
||||
|
||||
fun saveAsync(obj: T, withReconstruction: Boolean = false) {
|
||||
fun saveAsync(obj: T, withReconstruction: Boolean = false, onlyExisting: Boolean = false) {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
if(scope != null)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
save(obj, withReconstruction);
|
||||
save(obj, withReconstruction, onlyExisting);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save.", e);
|
||||
}
|
||||
};
|
||||
else
|
||||
save(obj, withReconstruction);
|
||||
save(obj, withReconstruction, onlyExisting);
|
||||
}
|
||||
fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
|
||||
+17
-10
@@ -33,7 +33,7 @@ class SmartSubscriptionAlgorithm(
|
||||
val client = it.value!! as JSClient;
|
||||
val capabilities = client.getChannelCapabilities();
|
||||
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED))
|
||||
if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
|
||||
return@flatMap listOf(SubscriptionTask(client, sub, it.key, ResultCapabilities.TYPE_MIXED));
|
||||
else {
|
||||
val types = listOf(
|
||||
@@ -42,21 +42,28 @@ class SmartSubscriptionAlgorithm(
|
||||
if(sub.shouldFetchPosts()) ResultCapabilities.TYPE_POSTS else null,
|
||||
if(sub.shouldFetchLiveStreams()) ResultCapabilities.TYPE_LIVE else null
|
||||
).filterNotNull().filter { capabilities.hasType(it) };
|
||||
return@flatMap types.map {
|
||||
SubscriptionTask(client, sub, url, it);
|
||||
};
|
||||
|
||||
if(!types.isEmpty())
|
||||
return@flatMap types.map {
|
||||
SubscriptionTask(client, sub, url, it);
|
||||
};
|
||||
else
|
||||
listOf(SubscriptionTask(client, sub, url, ResultCapabilities.TYPE_VIDEOS, true))
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
for(task in allTasks)
|
||||
task.urgency = calculateUpdateUrgency(task.sub, task.type);
|
||||
|
||||
val ordering = allTasks.groupBy { it.client }
|
||||
.map { Pair(it.key, it.value.sortedBy { calculateUpdateUrgency(it.sub, it.type) }) };
|
||||
.map { Pair(it.key, it.value.sortedBy { it.urgency }) };
|
||||
|
||||
val finalTasks = mutableListOf<SubscriptionTask>();
|
||||
|
||||
|
||||
for(clientTasks in ordering) {
|
||||
val limit = clientTasks.first.config.subscriptionRateLimit;
|
||||
val limit = clientTasks.first.getSubscriptionRateLimit();
|
||||
if(limit == null || limit <= 0)
|
||||
finalTasks.addAll(clientTasks.second);
|
||||
else {
|
||||
@@ -82,25 +89,25 @@ class SmartSubscriptionAlgorithm(
|
||||
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStream;
|
||||
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStream;
|
||||
ResultCapabilities.TYPE_POSTS -> sub.lastPost;
|
||||
else -> sub.lastVideo; //TODO: minimum of all
|
||||
else -> sub.lastVideo; //TODO: minimum of all?
|
||||
};
|
||||
val lastUpdate = when(type) {
|
||||
ResultCapabilities.TYPE_VIDEOS -> sub.lastVideoUpdate;
|
||||
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStreamUpdate;
|
||||
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStreamUpdate;
|
||||
ResultCapabilities.TYPE_POSTS -> sub.lastPostUpdate;
|
||||
else -> sub.lastVideoUpdate; //TODO: minimum of all
|
||||
else -> sub.lastVideoUpdate; //TODO: minimum of all?
|
||||
};
|
||||
val interval = when(type) {
|
||||
ResultCapabilities.TYPE_VIDEOS -> sub.uploadInterval;
|
||||
ResultCapabilities.TYPE_STREAMS -> sub.uploadStreamInterval;
|
||||
ResultCapabilities.TYPE_LIVE -> sub.uploadStreamInterval;
|
||||
ResultCapabilities.TYPE_POSTS -> sub.uploadPostInterval;
|
||||
else -> sub.uploadInterval; //TODO: minimum of all
|
||||
else -> sub.uploadInterval; //TODO: minimum of all?
|
||||
};
|
||||
val lastItemDaysAgo = lastItem.getNowDiffHours();
|
||||
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
|
||||
val expectedHours = lastUpdateHoursAgo.toDouble() - (interval*24);
|
||||
val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble();
|
||||
|
||||
return (expectedHours * 100).toInt();
|
||||
}
|
||||
|
||||
+105
-49
@@ -2,11 +2,13 @@ package com.futo.platformplayer.subscription
|
||||
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
@@ -15,8 +17,10 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCriticalException
|
||||
import com.futo.platformplayer.exceptions.ChannelException
|
||||
import com.futo.platformplayer.findNonRuntimeException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.SubscriptionsFeedFragment
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,24 +46,90 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||
val tasks = getSubscriptionTasks(subs);
|
||||
|
||||
val tasksGrouped = tasks.groupBy { it.client }
|
||||
val taskCount = tasks.filter { !it.fromCache }.size;
|
||||
val cacheCount = tasks.size - taskCount;
|
||||
|
||||
Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
|
||||
" Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
|
||||
" Cached: ${tasks.filter { it.fromCache }.size}");
|
||||
tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } })" }.joinToString("\n"));
|
||||
|
||||
try {
|
||||
//TODO: Remove this
|
||||
UIDialogs.toast("Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
|
||||
"Cached: ${tasks.filter { it.fromCache }.size}", false);
|
||||
for(clientTasks in tasksGrouped) {
|
||||
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
|
||||
val clientCacheCount = clientTasks.value.size - clientTaskCount;
|
||||
if(clientCacheCount > 0 && clientTaskCount > 0 && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive<SubscriptionsFeedFragment>() } == true) {
|
||||
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (ex: Throwable){}
|
||||
|
||||
val exs: ArrayList<Throwable> = arrayListOf();
|
||||
val taskResults = arrayListOf<IPager<IPlatformContent>>();
|
||||
|
||||
val failedPlugins = mutableListOf<String>();
|
||||
val cachedChannels = mutableListOf<String>()
|
||||
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
|
||||
|
||||
|
||||
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
val result = task.get();
|
||||
if(result != null) {
|
||||
if(result.pager != null)
|
||||
taskResults.add(result);
|
||||
else if(result.exception != null) {
|
||||
val ex = result.exception;
|
||||
if(ex != null) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex);
|
||||
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: ExecutionException) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex.cause);
|
||||
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
};
|
||||
}
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
|
||||
//Cache pagers grouped by channel
|
||||
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
||||
.map { entry ->
|
||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache };
|
||||
val cachedTasks = entry.value.filter { it.task.fromCache };
|
||||
val livePager = if(!liveTasks.isEmpty()) ChannelContentCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, {
|
||||
onNewCacheHit.emit(sub!!, it);
|
||||
}) else null;
|
||||
val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null;
|
||||
if(livePager != null && cachedPager == null)
|
||||
return@map livePager;
|
||||
else if(cachedPager != null && livePager == null)
|
||||
return@map cachedPager;
|
||||
else if(cachedPager == null && livePager == null)
|
||||
return@map EmptyPager();
|
||||
else
|
||||
return@map MultiChronoContentPager(listOf(livePager!!, cachedPager!!), true).apply { this.initialize() }
|
||||
}
|
||||
|
||||
val pager = MultiChronoContentPager(groupedPagers, allowFailure, 15);
|
||||
pager.initialize();
|
||||
|
||||
return Result(DedupContentPager(pager), exs);
|
||||
}
|
||||
|
||||
fun executeSubscriptionTasks(tasks: List<SubscriptionTask>, failedPlugins: MutableList<String>, cachedChannels: MutableList<String>): List<ForkJoinTask<SubscriptionTaskResult>> {
|
||||
val forkTasks = mutableListOf<ForkJoinTask<SubscriptionTaskResult>>();
|
||||
var finished = 0;
|
||||
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
|
||||
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
|
||||
val failedPlugins = arrayListOf<String>();
|
||||
val cachedChannels = arrayListOf<String>();
|
||||
|
||||
for(task in tasks) {
|
||||
val forkTask = threadPool.submit<SubscriptionTaskResult> {
|
||||
@@ -87,13 +157,9 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
pager = StatePlatform.instance.getChannelContent(task.client,
|
||||
task.url, task.type, ResultCapabilities.ORDER_CHONOLOGICAL);
|
||||
|
||||
pager = ChannelContentCache.cachePagerResults(scope, pager) {
|
||||
onNewCacheHit.emit(task.sub, it);
|
||||
};
|
||||
|
||||
val initialPage = pager.getResults();
|
||||
task.sub.updateSubscriptionState(task.type, initialPage);
|
||||
StateSubscriptions.instance.saveSubscription(task.sub);
|
||||
task.sub.save();
|
||||
|
||||
finished++;
|
||||
onProgress.emit(finished, forkTasks.size);
|
||||
@@ -105,6 +171,27 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
val channelEx = ChannelException(task.sub.channel, ex);
|
||||
finished++;
|
||||
onProgress.emit(finished, forkTasks.size);
|
||||
|
||||
|
||||
if(ex is ScriptCaptchaRequiredException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a captcha issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to Captcha");
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(ex is ScriptCriticalException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a critical issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!withCacheFallback)
|
||||
throw channelEx;
|
||||
else {
|
||||
@@ -117,39 +204,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
}
|
||||
forkTasks.add(forkTask);
|
||||
}
|
||||
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
val result = task.get();
|
||||
if(result != null) {
|
||||
if(result.pager != null)
|
||||
taskResults.add(result.pager!!);
|
||||
if(exceptionMap.containsKey(result.task.sub)) {
|
||||
val ex = exceptionMap[result.task.sub];
|
||||
if(ex != null) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex);
|
||||
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: ExecutionException) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex.cause);
|
||||
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
};
|
||||
}
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
val pager = MultiChronoContentPager(taskResults, allowFailure, 15);
|
||||
pager.initialize();
|
||||
|
||||
return Result(DedupContentPager(pager), exs);
|
||||
return forkTasks;
|
||||
}
|
||||
|
||||
abstract fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask>;
|
||||
@@ -160,7 +215,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
val sub: Subscription,
|
||||
val url: String,
|
||||
val type: String,
|
||||
var fromCache: Boolean = false
|
||||
var fromCache: Boolean = false,
|
||||
var urgency: Int = 0
|
||||
);
|
||||
|
||||
class SubscriptionTaskResult(
|
||||
|
||||
@@ -14,7 +14,8 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
private val _confirmationMessage: String;
|
||||
|
||||
var onClick = Event1<Subscription>();
|
||||
var sortBy: Int = 0
|
||||
var onSettings = Event1<Subscription>();
|
||||
var sortBy: Int = 3
|
||||
set(value) {
|
||||
field = value;
|
||||
updateDataset();
|
||||
@@ -33,12 +34,16 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder {
|
||||
val holder = SubscriptionViewHolder(viewGroup);
|
||||
holder.onClick.subscribe(onClick::emit);
|
||||
holder.onSettings.subscribe(onSettings::emit);
|
||||
holder.onTrash.subscribe {
|
||||
val sub = holder.subscription ?: return@subscribe;
|
||||
UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, {
|
||||
StateSubscriptions.instance.removeSubscription(sub.channel.url);
|
||||
});
|
||||
};
|
||||
holder.onSettings.subscribe {
|
||||
onSettings.emit(it);
|
||||
};
|
||||
|
||||
return holder;
|
||||
}
|
||||
@@ -49,11 +54,20 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
|
||||
private fun updateDataset() {
|
||||
_sortedDataset = when (sortBy) {
|
||||
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name })
|
||||
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name })
|
||||
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name.lowercase() })
|
||||
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name.lowercase() })
|
||||
2 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackViews * VIEW_PRIORITY + it.playbackSeconds }
|
||||
3 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackViews * VIEW_PRIORITY + it.playbackSeconds }
|
||||
4 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackSeconds }
|
||||
5 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds }
|
||||
else -> throw IllegalStateException("Invalid sorting algorithm selected.");
|
||||
}.toList();
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val VIEW_PRIORITY = 36000 * 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -17,6 +19,9 @@ import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.selectBestImage
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.platformplayer.toHumanTimeIndicator
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||
@@ -26,12 +31,14 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
private val _textName: TextView;
|
||||
private val _creatorThumbnail: CreatorThumbnail;
|
||||
private val _buttonTrash: ImageButton;
|
||||
private val _buttonSettings: ImageButton;
|
||||
private val _platformIndicator : PlatformIndicator;
|
||||
private val _textMeta: TextView;
|
||||
|
||||
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
|
||||
StateApp.instance.scopeGetter,
|
||||
{ PolycentricCache.instance.getProfileAsync(it) })
|
||||
.success { it -> onProfileLoaded(it, true) }
|
||||
.success { it -> onProfileLoaded(null, it, true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load profile.", it);
|
||||
};
|
||||
@@ -41,12 +48,15 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
|
||||
var onClick = Event1<Subscription>();
|
||||
var onTrash = Event0();
|
||||
var onSettings = Event1<Subscription>();
|
||||
|
||||
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) {
|
||||
_layoutSubscription = itemView.findViewById(R.id.layout_subscription);
|
||||
_textName = itemView.findViewById(R.id.text_name);
|
||||
_textMeta = itemView.findViewById(R.id.text_meta);
|
||||
_creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail);
|
||||
_buttonTrash = itemView.findViewById(R.id.button_trash);
|
||||
_buttonSettings = itemView.findViewById(R.id.button_settings);
|
||||
_platformIndicator = itemView.findViewById(R.id.platform);
|
||||
|
||||
_layoutSubscription.setOnClickListener {
|
||||
@@ -59,6 +69,11 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
_buttonTrash.setOnClickListener {
|
||||
onTrash.emit();
|
||||
};
|
||||
_buttonSettings.setOnClickListener {
|
||||
subscription?.let {
|
||||
onSettings.emit(it);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(sub: Subscription) {
|
||||
@@ -68,17 +83,18 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
|
||||
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
|
||||
if (cachedProfile != null) {
|
||||
onProfileLoaded(cachedProfile, false);
|
||||
onProfileLoaded(sub, cachedProfile, false);
|
||||
} else {
|
||||
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
|
||||
_taskLoadProfile.run(sub.channel.id);
|
||||
_textName.text = sub.channel.name;
|
||||
bindViewMetrics(sub);
|
||||
}
|
||||
|
||||
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId);
|
||||
}
|
||||
|
||||
private fun onProfileLoaded(cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
||||
private fun onProfileLoaded(sub: Subscription?, cachedPolycentricProfile: PolycentricCache.CachedPolycentricProfile?, animate: Boolean) {
|
||||
val dp_46 = 46.dp(itemView.context.resources);
|
||||
val profile = cachedPolycentricProfile?.profile;
|
||||
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
|
||||
@@ -94,6 +110,19 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
if (profile != null) {
|
||||
_textName.text = profile.systemState.username;
|
||||
}
|
||||
|
||||
if(sub != null)
|
||||
bindViewMetrics(sub)
|
||||
}
|
||||
|
||||
fun bindViewMetrics(sub: Subscription?) {
|
||||
if(sub == null || !Settings.instance.subscriptions.showWatchMetrics)
|
||||
_textMeta.text = "";
|
||||
else
|
||||
_textMeta.text = listOf(
|
||||
if(sub.playbackViews > 0) "${sub.playbackViews} view" + (if(sub.playbackViews > 1) "s" else "") else null,
|
||||
if(sub.playbackSeconds > 0) sub.playbackSeconds.toHumanTimeIndicator() else null
|
||||
).filterNotNull().joinToString(" · ");
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -21,10 +21,13 @@ class SourceHeaderView : LinearLayout {
|
||||
private val _sourceDescription: TextView;
|
||||
|
||||
private val _sourceVersion: TextView;
|
||||
private val _sourcePlatformUrl: TextView;
|
||||
private val _sourceRepositoryUrl: TextView;
|
||||
private val _sourceScriptUrl: TextView;
|
||||
private val _sourceSignature: TextView;
|
||||
|
||||
private val _sourcePlatformUrlContainer: LinearLayout;
|
||||
|
||||
private var _config : SourcePluginConfig? = null;
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
@@ -38,6 +41,8 @@ class SourceHeaderView : LinearLayout {
|
||||
|
||||
_sourceVersion = findViewById(R.id.source_version);
|
||||
_sourceRepositoryUrl = findViewById(R.id.source_repo);
|
||||
_sourcePlatformUrl = findViewById(R.id.source_platform);
|
||||
_sourcePlatformUrlContainer = findViewById(R.id.source_platform_container);
|
||||
_sourceScriptUrl = findViewById(R.id.source_script);
|
||||
_sourceSignature = findViewById(R.id.source_signature);
|
||||
|
||||
@@ -53,6 +58,10 @@ class SourceHeaderView : LinearLayout {
|
||||
if(!_config?.absoluteScriptUrl.isNullOrEmpty())
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl)));
|
||||
};
|
||||
_sourcePlatformUrl.setOnClickListener {
|
||||
if(!_config?.platformUrl.isNullOrEmpty())
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.platformUrl)));
|
||||
};
|
||||
}
|
||||
|
||||
fun loadConfig(config: SourcePluginConfig, script: String?) {
|
||||
@@ -74,6 +83,12 @@ class SourceHeaderView : LinearLayout {
|
||||
_sourceRepositoryUrl.text = config.repositoryUrl;
|
||||
_sourceAuthorID.text = "";
|
||||
|
||||
_sourcePlatformUrl.text = config.platformUrl ?: "";
|
||||
if(!config.platformUrl.isNullOrEmpty())
|
||||
_sourcePlatformUrlContainer.visibility = VISIBLE;
|
||||
else
|
||||
_sourcePlatformUrlContainer.visibility = GONE;
|
||||
|
||||
if(!config.authorUrl.isNullOrEmpty())
|
||||
_sourceBy.setTextColor(resources.getColor(R.color.colorPrimary));
|
||||
else
|
||||
@@ -105,5 +120,7 @@ class SourceHeaderView : LinearLayout {
|
||||
_sourceScriptUrl.text = "";
|
||||
_sourceRepositoryUrl.text = "";
|
||||
_sourceAuthorID.text = "";
|
||||
_sourcePlatformUrl.text = "";
|
||||
_sourcePlatformUrlContainer.visibility = GONE;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class SubscribeButton : LinearLayout {
|
||||
} else { null };
|
||||
|
||||
val onSubscribed = Event1<Subscription>();
|
||||
val onUnSubscribed = Event1<String>();
|
||||
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
|
||||
@@ -82,6 +83,7 @@ class SubscribeButton : LinearLayout {
|
||||
if (removed != null)
|
||||
UIDialogs.toast(context, context.getString(R.string.unsubscribed_from) + removed.channel.name);
|
||||
setIsSubscribed(false);
|
||||
onUnSubscribed.emit(url);
|
||||
}
|
||||
|
||||
fun setSubscribeChannel(url: String) {
|
||||
|
||||
@@ -25,7 +25,7 @@ class SubscriptionBar : LinearLayout {
|
||||
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
|
||||
inflate(context, R.layout.view_subscription_bar, this);
|
||||
|
||||
val subscriptions = StateSubscriptions.instance.getSubscriptions();
|
||||
val subscriptions = StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds };
|
||||
_adapterView = findViewById<RecyclerView>(R.id.recycler_creators).asAny(subscriptions, orientation = RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe { c ->
|
||||
onClickChannel.emit(c.channel);
|
||||
|
||||
@@ -100,6 +100,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
val onSourceChanged = Event3<IVideoSource?, IAudioSource?, Boolean>();
|
||||
val onSourceEnded = Event0();
|
||||
|
||||
val onChapterChanged = Event2<IChapter?, Boolean>();
|
||||
|
||||
val onVideoClicked = Event0();
|
||||
val onTimeBarChanged = Event2<Long, Long>();
|
||||
|
||||
@@ -185,6 +187,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) {
|
||||
gestureControl.restartHideJob();
|
||||
|
||||
updateCurrentChapter(position);
|
||||
}
|
||||
|
||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||
@@ -233,17 +237,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
val delta = position - lastPos;
|
||||
if(delta > 1000 || delta < 0) {
|
||||
lastPos = position;
|
||||
val currentChapter = getCurrentChapter(position)
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
if (currentChapter != null) {
|
||||
_control_chapter.text = " • " + currentChapter.name;
|
||||
_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
} else {
|
||||
_control_chapter.text = "";
|
||||
_control_chapter_fullscreen.text = "";
|
||||
}
|
||||
}
|
||||
updateCurrentChapter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +250,22 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
exoPlayer?.attach(_videoView, PLAYER_STATE_NAME);
|
||||
}
|
||||
|
||||
fun updateCurrentChapter(pos: Long? = null) {
|
||||
val chaptPos = pos ?: position;
|
||||
val currentChapter = getCurrentChapter(chaptPos);
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
if (currentChapter != null) {
|
||||
_control_chapter.text = " • " + currentChapter.name;
|
||||
_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
} else {
|
||||
_control_chapter.text = "";
|
||||
_control_chapter_fullscreen.text = "";
|
||||
}
|
||||
onChapterChanged.emit(currentChapter, pos != null);
|
||||
}
|
||||
}
|
||||
|
||||
fun setArtwork(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
_videoView.defaultArtwork = drawable;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#000000" />
|
||||
<corners android:radius="4dp" />
|
||||
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
|
||||
</shape>
|
||||
@@ -2,8 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M120,800L120,720L600,720L600,800L120,800ZM640,520Q557,520 498.5,461.5Q440,403 440,320Q440,237 498.5,178.5Q557,120 640,120Q723,120 781.5,178.5Q840,237 840,320Q840,403 781.5,461.5Q723,520 640,520ZM120,480L120,400L372,400Q379,422 388,442Q397,462 410,480L120,480ZM120,640L120,560L496,560Q519,574 545,583.5Q571,593 600,597L600,640L120,640ZM620,360L660,360L660,200L620,200L620,360ZM640,440Q648,440 654,434Q660,428 660,420Q660,412 654,406Q648,400 640,400Q632,400 626,406Q620,412 620,420Q620,428 626,434Q632,440 640,440Z"/>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M420,600L540,600L517,471Q537,461 548.5,442Q560,423 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,423 411.5,442Q423,461 443,471L420,600ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:autoMirrored="true">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M120,720L120,640L600,640L600,720L120,720ZM120,520L120,440L840,440L840,520L120,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
|
||||
</vector>
|
||||
@@ -2,8 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M560,600Q577,600 589.5,587.5Q602,575 602,558Q602,541 589.5,528.5Q577,516 560,516Q543,516 530.5,528.5Q518,541 518,558Q518,575 530.5,587.5Q543,600 560,600ZM530,472L590,472Q590,443 596,429.5Q602,416 624,394Q654,364 664,345.5Q674,327 674,302Q674,257 642.5,228.5Q611,200 560,200Q519,200 488.5,223Q458,246 446,284L500,306Q509,281 524.5,268.5Q540,256 560,256Q584,256 599,269.5Q614,283 614,306Q614,320 606,332.5Q598,345 578,364Q545,393 537.5,409.5Q530,426 530,472ZM320,720Q287,720 263.5,696.5Q240,673 240,640L240,160Q240,127 263.5,103.5Q287,80 320,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L320,720ZM320,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640ZM160,880Q127,880 103.5,856.5Q80,833 80,800L80,240L160,240L160,800Q160,800 160,800Q160,800 160,800L720,800L720,880L160,880ZM320,160L320,160Q320,160 320,160Q320,160 320,160L320,640Q320,640 320,640Q320,640 320,640L320,640Q320,640 320,640Q320,640 320,640L320,160Q320,160 320,160Q320,160 320,160Z"/>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,720L800,720Q800,720 800,720Q800,720 800,720L800,400L520,400L520,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L160,720Z"/>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M640,480L640,400L720,400L720,480L640,480ZM640,560L560,560L560,480L640,480L640,560ZM640,640L640,560L720,560L720,640L640,640ZM447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L560,720L560,640L640,640L640,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L640,320L640,400L560,400L560,320L447,320ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Q160,320 160,320Q160,320 160,320L160,320L160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720Z"/>
|
||||
</vector>
|
||||
@@ -3,10 +3,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_big_button"
|
||||
@@ -26,6 +24,8 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/button_text"
|
||||
@@ -47,7 +47,7 @@
|
||||
android:textSize="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:maxLines="1"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end"
|
||||
tools:text="Attempts to fetch your subscriptions from this source" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:background="@color/gray_1d">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingTop="40dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/update_spinner"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
app:srcCompat="@drawable/ic_move_up" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:layout_gravity="center"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_dialog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/there_is_an_update_available_do_you_wish_to_update"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="10dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp">
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:background="@color/gray_1d">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingTop="40dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/update_spinner"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
app:srcCompat="@drawable/ic_move_up" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:layout_gravity="center"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_dialog"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/import_options"
|
||||
android:textAlignment="center"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="30dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="10dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginBottom="28dp">
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:buttonIcon="@drawable/ic_zip"
|
||||
app:buttonText="Import Grayjay export (.zip)"
|
||||
android:layout_margin="5dp"
|
||||
app:buttonBackground="@drawable/background_big_button_black"
|
||||
app:buttonSubText="Pick a Grayjay export zip file" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:buttonIcon="@drawable/ic_encrypted"
|
||||
android:alpha="0.5"
|
||||
app:buttonBackground="@drawable/background_big_button_black"
|
||||
app:buttonText="Import Grayjay Auto-Backup (.ezip)"
|
||||
android:layout_margin="5dp"
|
||||
app:buttonSubText="Pick a Grayjay auto-backup encrypted zip file" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="5dp"
|
||||
app:buttonIcon="@drawable/ic_lines"
|
||||
android:alpha="0.5"
|
||||
app:buttonBackground="@drawable/background_big_button_black"
|
||||
app:buttonText="Import Line Text file (.txt)"
|
||||
app:buttonSubText="Pick a text file with one entry per line" />
|
||||
<com.futo.platformplayer.views.buttons.BigButton
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="5dp"
|
||||
app:buttonIcon="@drawable/ic_play"
|
||||
app:buttonBackground="@drawable/background_big_button_black"
|
||||
app:buttonText="Import NewPipe Subscriptions (.json)"
|
||||
app:buttonSubText="Pick a NewPipe subscriptions json file" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_cancel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/close"
|
||||
android:layout_marginTop="20dp"
|
||||
android:textSize="14dp"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textColor="@color/colorPrimary"
|
||||
android:background="@color/transparent" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -55,4 +55,11 @@
|
||||
android:clipToPadding="false"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:elevation="100dp"
|
||||
android:visibility="gone" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -150,6 +150,30 @@
|
||||
android:textSize="12dp"
|
||||
android:fontFamily="@font/inter_light" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_skip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="5dp"
|
||||
android:background="@drawable/background_button_transparent_round"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_skip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Skip"
|
||||
android:textSize="12dp"
|
||||
android:fontFamily="@font/inter_light" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/contentContainer"
|
||||
|
||||
@@ -35,19 +35,45 @@
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
android:id="@+id/platform"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp" />
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
<com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
android:id="@+id/platform"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp" />
|
||||
<TextView
|
||||
android:id="@+id/text_meta"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11dp"
|
||||
android:textColor="#666666"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginLeft="5dp"
|
||||
android:text="Testing " />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_settings"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="40dp"
|
||||
app:srcCompat="@drawable/ic_settings"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingStart="5dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:paddingEnd="0dp" />
|
||||
<ImageButton
|
||||
android:id="@+id/button_trash"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:srcCompat="@drawable/ic_trash_18dp"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:paddingEnd="20dp" />
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="40dp"
|
||||
app:srcCompat="@drawable/ic_trash"
|
||||
android:scaleType="fitCenter"
|
||||
android:paddingStart="5dp"
|
||||
android:paddingTop="5dp"
|
||||
android:paddingBottom="5dp"
|
||||
android:paddingEnd="5dp" />
|
||||
</LinearLayout>
|
||||
@@ -100,6 +100,30 @@
|
||||
tools:text="3" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--Platform Url-->
|
||||
<LinearLayout
|
||||
android:id="@+id/source_platform_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:layout_marginTop="10dp"
|
||||
android:fontFamily="@font/inter_light"
|
||||
android:text="@string/platform_url" />
|
||||
<TextView
|
||||
android:id="@+id/source_platform"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14dp"
|
||||
android:textColor="@color/colorPrimary"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
tools:text="https://some.platform.url" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--Repo Url-->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="update">Update</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="never">Never</string>
|
||||
<string name="import_options">Select any of the following available import options.</string>
|
||||
<string name="there_is_an_update_available_do_you_wish_to_update">There is an update available, do you wish to update?</string>
|
||||
<string name="downloading_update">Downloading update…</string>
|
||||
<string name="installing_update">Installing update…</string>
|
||||
@@ -98,6 +99,7 @@
|
||||
<string name="are_you_sure_delete_historical">Are you sure you want to remove these historical entries?</string>
|
||||
<string name="removed">removed</string>
|
||||
<string name="add_source">Add Source</string>
|
||||
<string name="platform_url">Platform URL</string>
|
||||
<string name="repository_url">Repository URL</string>
|
||||
<string name="script_url">Script URL</string>
|
||||
<string name="source_permissions_explanation">These are the permissions the plugin requires to function</string>
|
||||
@@ -276,6 +278,8 @@
|
||||
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string>
|
||||
<string name="clear_cookies">Clear Cookies</string>
|
||||
<string name="clear_cookies_on_logout">Clear Cookies on Logout</string>
|
||||
<string name="test_background_worker">Test Background Worker</string>
|
||||
<string name="test_background_worker_description"></string>
|
||||
<string name="clear_payment">Clear Payment</string>
|
||||
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
|
||||
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
|
||||
@@ -299,6 +303,8 @@
|
||||
<string name="enable_casting">Enable casting</string>
|
||||
<string name="experimental_background_update_for_subscriptions_cache">Experimental background update for subscriptions cache</string>
|
||||
<string name="export_data">Export Data</string>
|
||||
<string name="import_data">Import Data</string>
|
||||
<string name="import_data_description">Select a file to import, support various files (alternative to opening directly)</string>
|
||||
<string name="external_storage">External Storage</string>
|
||||
<string name="feed_style">Feed Style</string>
|
||||
<string name="fetch_on_app_boot">Fetch on app boot</string>
|
||||
@@ -339,6 +345,10 @@
|
||||
<string name="submit_logs">Submit logs</string>
|
||||
<string name="submit_logs_to_help_us_narrow_down_issues">Submit logs to help us narrow down issues</string>
|
||||
<string name="subscription_concurrency">Subscription Concurrency</string>
|
||||
<string name="track_playtime_locally">Track Playtime Locally</string>
|
||||
<string name="track_playtime_locally_description">Locally track playtime of subscriptions, used for subscriptions ordering and local creator recommendations.</string>
|
||||
<string name="show_watch_metrics">Show Watch Metrics</string>
|
||||
<string name="show_watch_metrics_description">Shows the watch time and views of each creator in the creators tab</string>
|
||||
<string name="this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees">This prevents the device from rotating within the given amount of degrees</string>
|
||||
<string name="use_the_live_chat_web_window_when_available_over_native_implementation">Use the live chat web window when available over native implementation</string>
|
||||
<string name="version_code">Version Code</string>
|
||||
@@ -390,6 +400,10 @@
|
||||
<string name="various_tests_against_a_custom_source">Various tests against a custom source</string>
|
||||
<string name="writes_to_disk_till_no_space_is_left">Writes to disk till no space is left</string>
|
||||
<string name="visibility">Visibility</string>
|
||||
<string name="ratelimit">Rate-limit</string>
|
||||
<string name="ratelimit_description">Settings related to rate-limiting this plugin\'s behavior</string>
|
||||
<string name="ratelimit_sub_setting">Rate-limit Subscriptions</string>
|
||||
<string name="ratelimit_sub_setting_description">Limit the amount of subscription requests made</string>
|
||||
<string name="enable_where_this_plugins_content_are_visible">Enable where this plugin\'s content are visible</string>
|
||||
<string name="show_content_in_home_tab">Show content in home tab</string>
|
||||
<string name="show_content_in_search_results">Show content in search results</string>
|
||||
@@ -421,6 +435,7 @@
|
||||
<string name="unknown_url_format">Unknown url format</string>
|
||||
<string name="failed_to_handle_file">Failed to handle file</string>
|
||||
<string name="unknown_reconstruction_type">Unknown reconstruction type</string>
|
||||
<string name="failed_to_parse_text_file">Failed to parse text file</string>
|
||||
<string name="failed_to_parse_newpipe_subscriptions">Failed to parse NewPipe Subscriptions</string>
|
||||
<string name="failed_to_generate_qr_code">Failed to generate QR code</string>
|
||||
<string name="share_text">Share Text</string>
|
||||
@@ -700,6 +715,10 @@
|
||||
<string-array name="subscriptions_sortby_array">
|
||||
<item>Name Ascending</item>
|
||||
<item>Name Descending</item>
|
||||
<item>Views Ascending</item>
|
||||
<item>Views Descending</item>
|
||||
<item>Watchtime Ascending</item>
|
||||
<item>Watchtime Descending</item>
|
||||
</string-array>
|
||||
<string-array name="feed_style">
|
||||
<item>Preview</item>
|
||||
|
||||
Submodule app/src/stable/assets/sources/kick updated: 82aa06b98e...63790c2dc8
Submodule app/src/stable/assets/sources/nebula updated: 8ea9393634...dcc004d722
Submodule app/src/stable/assets/sources/odysee updated: cbde0c9e9c...b6db44bf3b
Submodule app/src/stable/assets/sources/patreon updated: 6037691859...ecf4988b3f
Submodule app/src/stable/assets/sources/rumble updated: 4b5d9f12a7...1b70c84e30
Submodule app/src/stable/assets/sources/soundcloud updated: eff8285222...af99093027
Submodule app/src/stable/assets/sources/twitch updated: eb198a3d20...1d3c8b7955
Submodule app/src/stable/assets/sources/youtube updated: a1d432865e...b41f7ed976
Submodule app/src/unstable/assets/sources/kick updated: 82aa06b98e...63790c2dc8
Submodule app/src/unstable/assets/sources/nebula updated: 8ea9393634...dcc004d722
Submodule app/src/unstable/assets/sources/odysee updated: cbde0c9e9c...b6db44bf3b
Submodule app/src/unstable/assets/sources/patreon updated: 6037691859...ecf4988b3f
Submodule app/src/unstable/assets/sources/rumble updated: 4b5d9f12a7...1b70c84e30
Submodule app/src/unstable/assets/sources/soundcloud updated: eff8285222...af99093027
Submodule app/src/unstable/assets/sources/twitch updated: eb198a3d20...1d3c8b7955
Submodule app/src/unstable/assets/sources/youtube updated: 5011bfcddb...b41f7ed976
Reference in New Issue
Block a user