Compare commits

...

10 Commits

49 changed files with 493 additions and 99 deletions
@@ -185,6 +185,25 @@ fun OffsetDateTime.toHumanNowDiffString(abs: Boolean = false) : String {
return "${value} ${unit}"; 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 { fun Long.toHumanTime(isMs: Boolean): String {
var scaler = 1; var scaler = 1;
@@ -86,6 +86,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
/*
@FormField( @FormField(
R.string.submit_feedback, FieldForm.BUTTON, R.string.submit_feedback, FieldForm.BUTTON,
R.string.give_feedback_on_the_application, -1 R.string.give_feedback_on_the_application, -1
@@ -104,7 +105,7 @@ class Settings : FragmentedStorageFileJson() {
} catch (e: Throwable) { } catch (e: Throwable) {
//Ignored //Ignored
} }
} }*/
@FormField( @FormField(
R.string.manage_tabs, FieldForm.BUTTON, R.string.manage_tabs, FieldForm.BUTTON,
@@ -201,6 +202,12 @@ class Settings : FragmentedStorageFileJson() {
fun getSubscriptionsConcurrency() : Int { fun getSubscriptionsConcurrency() : Int {
return threadIndexToCount(subscriptionConcurrency); 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) @FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4)
@@ -2,14 +2,24 @@ package com.futo.platformplayer
import android.content.Context import android.content.Context
import android.webkit.CookieManager 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.V8ValueInteger
import com.caoccao.javet.values.primitive.V8ValueString 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.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@@ -28,6 +38,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.* import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -87,11 +99,23 @@ class SettingsDev : FragmentedStorageFileJson() {
val cookieManager: CookieManager = CookieManager.getInstance() val cookieManager: CookieManager = CookieManager.getInstance()
cookieManager.removeAllCookies(null); 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 @Contextual
@Transient @Transient
@FormField(R.string.v8_benchmarks, FieldForm.GROUP, @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(); val v8Benchmarks: V8Benchmarks = V8Benchmarks();
class V8Benchmarks { class V8Benchmarks {
@FormField( @FormField(
@@ -139,7 +163,7 @@ class SettingsDev : FragmentedStorageFileJson() {
@FormField( @FormField(
R.string.test_v8_communication_speed, FieldForm.BUTTON, R.string.test_v8_communication_speed, FieldForm.BUTTON,
R.string.tests_v8_communication_speeds, 2 R.string.tests_v8_communication_speeds, 4
) )
fun testV8RunSpeeds() { fun testV8RunSpeeds() {
var plugin: V8Plugin? = null; var plugin: V8Plugin? = null;
@@ -92,7 +92,7 @@ class UISlideOverlays {
menu.selectOption(null, "fetchPosts", true, true); menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe { menu.onOK.subscribe {
StateSubscriptions.instance.saveSubscription(subscription); subscription.save();
menu.hide(true); menu.hide(true);
}; };
menu.onCancel.subscribe { menu.onCancel.subscribe {
@@ -92,6 +92,19 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: 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 onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>(); val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
@@ -41,6 +41,7 @@ class SourcePluginConfig(
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var platformUrl: String? = null,
var subscriptionRateLimit: Int? = null, var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer 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.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -79,6 +80,29 @@ class SourcePluginDescriptor {
var enableSearch: Boolean? = null; 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) { fun loadDefaults(config: SourcePluginConfig) {
@@ -4,6 +4,8 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.media.MediaSession2Service.MediaNotification import android.media.MediaSession2Service.MediaNotification
import androidx.concurrent.futures.CallbackToFutureAdapter import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.concurrent.futures.ResolvableFuture import androidx.concurrent.futures.ResolvableFuture
@@ -11,8 +13,12 @@ import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.WorkerParameters 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.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
@@ -29,10 +35,10 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.OffsetDateTime 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) { CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
if(StateApp.instance.isMainActive) { if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
Logger.i("BackgroundWorker", "CANCELLED"); Logger.i("BackgroundWorker", "CANCELLED");
return Result.success(); return Result.success();
} }
@@ -86,9 +92,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
val newSubChanges = hashSetOf<Subscription>(); val newSubChanges = hashSetOf<Subscription>();
val newItems = mutableListOf<IPlatformContent>(); val newItems = mutableListOf<IPlatformContent>();
val now = OffsetDateTime.now();
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>(); val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
withContext(Dispatchers.IO) { 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}"); Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
synchronized(manager) { synchronized(manager) {
@@ -103,29 +110,46 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
synchronized(newSubChanges) { synchronized(newSubChanges) {
if(!newSubChanges.contains(sub)) { if(!newSubChanges.contains(sub)) {
newSubChanges.add(sub); newSubChanges.add(sub);
if(sub.doNotifications) if(sub.doNotifications && content.datetime?.let { it < now } == true)
contentNotifs.add(Pair(sub, content)); contentNotifs.add(Pair(sub, content));
} }
newItems.add(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); manager.cancel(12);
if(newItems.size > 0) { if(contentNotifs.size > 0) {
try { try {
val items = contentNotifs.take(5).toList() val items = contentNotifs.take(5).toList()
for(i in items.indices) { for(i in items.indices) {
val contentNotif = items.get(i); val contentNotif = items.get(i);
manager.notify(13 + i, NotificationCompat.Builder(appContext, notificationChannel.id) val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground) else null;
.setContentTitle("New video by [${contentNotif.first.channel.name}]") if(thumbnail != null)
.setContentText("${contentNotif.second.name}") Glide.with(appContext).asBitmap()
.setSilent(true) .load(thumbnail)
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, contentNotif.second.url), .into(object: CustomTarget<Bitmap>() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
.setChannelId(notificationChannel.id).build()); 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) { catch(ex: Throwable) {
@@ -140,4 +164,20 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id).build());*/ .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());
}
} }
@@ -18,10 +18,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.streams.toList
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
class ChannelContentCache { class ChannelContentCache {
private val _targetCacheSize = 2000; private val _targetCacheSize = 3000;
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>; val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
init { init {
@@ -29,11 +30,11 @@ class ChannelContentCache {
val initializeTime = measureTimeMillis { val initializeTime = measureTimeMillis {
_channelContents = HashMap(allFiles _channelContents = HashMap(allFiles
.filter { it.isDirectory } .filter { it.isDirectory }
.associate { .parallelStream().map {
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer()) Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
.withoutBackup() .withoutBackup()
.load()) .load())
}); }.toList().associate { it })
} }
val minDays = OffsetDateTime.now().minusDays(10); val minDays = OffsetDateTime.now().minusDays(10);
val totalItems = _channelContents.map { it.value.count() }.sum(); val totalItems = _channelContents.map { it.value.count() }.sum();
@@ -41,7 +42,7 @@ class ChannelContentCache {
val trimmed: Int; val trimmed: Int;
if(toTrim > 0) { if(toTrim > 0) {
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) } val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
.sortedByDescending { it.datetime!! }.take(toTrim); .sortedBy { it.datetime!! }.take(toTrim);
for(content in redundantContent) for(content in redundantContent)
uncacheContent(content); uncacheContent(content);
trimmed = redundantContent.size; trimmed = redundantContent.size;
@@ -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.IRefreshPager
import com.futo.platformplayer.api.media.structures.IReplacerPager import com.futo.platformplayer.api.media.structures.IReplacerPager
import com.futo.platformplayer.api.media.structures.MultiPager 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.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler 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.FeedView
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
@@ -74,9 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, { private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
return@TaskHandler getContentPager(it); return@TaskHandler getContentPager(it);
}).success { }).success { livePager ->
setLoading(false); 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<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
@@ -170,6 +170,10 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.onSubscribed.subscribe { _buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); 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 { _buttonSubscriptionSettings.setOnClickListener {
@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.Spinner import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.views.adapters.SubscriptionAdapter import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() { class CreatorsFragment : MainFragment() {
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _spinnerSortBy: Spinner? = null; private var _spinnerSortBy: Spinner? = null;
private var _overlayContainer: FrameLayout? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; 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); 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 { 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); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_spinnerSortBy = null; _spinnerSortBy = null;
_overlayContainer = null;
} }
companion object { companion object {
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
_toolbarContentView = findViewById(R.id.container_toolbar_content); _toolbarContentView = findViewById(R.id.container_toolbar_content);
var filteredNextPageCounter = 0;
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, { _nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
if (it is IAsyncPager<*>) if (it is IAsyncPager<*>)
it.nextPageAsync(); it.nextPageAsync();
@@ -141,10 +142,15 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it); val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults); recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it); recyclerData.resultsUnfiltered.addAll(it);
if(filteredResults.isEmpty()) if(filteredResults.isEmpty()) {
loadNextPage() filteredNextPageCounter++
else if(filteredNextPageCounter <= 4)
loadNextPage()
}
else {
filteredNextPageCounter = 0;
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size); recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
@@ -210,7 +210,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75; private const val MAXIMUM_BATCH_SIZE = 90;
fun newInstance() = ImportSubscriptionsFragment().apply {} fun newInstance() = ImportSubscriptionsFragment().apply {}
} }
} }
@@ -118,7 +118,11 @@ class SubscriptionsFeedFragment : MainFragment() {
if(recyclerData.loadedFeedStyle != feedStyle || if(recyclerData.loadedFeedStyle != feedStyle ||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) { recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now(); recyclerData.lastLoad = OffsetDateTime.now();
loadResults();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
loadResults(false);
else if(recyclerData.results.size == 0)
loadCache();
} }
val announcementsView = _announcementsView; val announcementsView = _announcementsView;
@@ -172,8 +176,8 @@ class SubscriptionsFeedFragment : MainFragment() {
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh -> private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
if(!_bypassRateLimit) { if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true } val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr); Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr);
if(rateLimitPlugins.any()) if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id }); throw RateLimitException(rateLimitPlugins.map { it.key.id });
@@ -201,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), 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", { subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
_bypassRateLimit = true; _bypassRateLimit = true;
loadResults(); loadResults(true);
}, UIDialogs.ActionStyle.DANGEROUS_TEXT), }, UIDialogs.ActionStyle.DANGEROUS_TEXT),
UIDialogs.Action("OK", { UIDialogs.Action("OK", {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
@@ -213,7 +217,7 @@ class SubscriptionsFeedFragment : MainFragment() {
.exception<Throwable> { .exception<Throwable> {
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it); Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
if(it !is CancellationException) if(it !is CancellationException)
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
else { else {
finishRefreshLayoutLoader(); finishRefreshLayoutLoader();
setLoading(false); setLoading(false);
@@ -278,16 +282,19 @@ class SubscriptionsFeedFragment : MainFragment() {
loadResults(true); 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) { private fun loadResults(withRefetch: Boolean = false) {
setLoading(true); setLoading(true);
Logger.i(TAG, "Subscriptions load"); Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) { if(recyclerData.results.size == 0) {
Logger.i(TAG, "Subscriptions load cache"); loadCache();
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);
} else { } else {
setTextCentered(null); setTextCentered(null);
} }
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri import android.net.Uri
import android.provider.Browser
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import android.text.Spanned import android.text.Spanned
import android.util.AttributeSet import android.util.AttributeSet
@@ -23,7 +22,6 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.widget.* import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -66,6 +64,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.helpers.VideoHelper
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.receivers.MediaControlReceiver import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
@@ -96,7 +95,6 @@ import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.ui.PlayerControlView import com.google.android.exoplayer2.ui.PlayerControlView
import com.google.android.exoplayer2.ui.TimeBar import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
import com.google.common.base.Stopwatch
import com.google.protobuf.ByteString import com.google.protobuf.ByteString
import kotlinx.coroutines.* import kotlinx.coroutines.*
import userpackage.Protocol import userpackage.Protocol
@@ -436,6 +434,7 @@ class VideoDetailView : ConstraintLayout {
if (!_isCasting && !_didStop) { if (!_isCasting && !_didStop) {
setLastPositionMilliseconds(position, true); setLastPositionMilliseconds(position, true);
} }
updatePlaybackTracking(position);
}; };
_player.onVideoClicked.subscribe { _player.onVideoClicked.subscribe {
@@ -610,6 +609,61 @@ class VideoDetailView : ConstraintLayout {
} }
} }
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() { fun updateMoreButtons() {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) { val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(video ?: _searchVideo)?.let { (video ?: _searchVideo)?.let {
@@ -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.IPlatformChannel
import com.futo.platformplayer.api.media.models.channels.SerializedChannel 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.IPlatformContent
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
import com.futo.platformplayer.getNowDiffDays import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions
import java.time.OffsetDateTime import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
@@ -43,22 +46,41 @@ class Subscription {
var uploadStreamInterval : Int = 0; var uploadStreamInterval : Int = 0;
var uploadPostInterval : Int = 0; var uploadPostInterval : Int = 0;
var playbackSeconds: Int = 0;
var playbackViews: Int = 0;
constructor(channel : SerializedChannel) { constructor(channel : SerializedChannel) {
this.channel = channel; this.channel = channel;
} }
fun shouldFetchVideos() = true; fun shouldFetchVideos() = doFetchVideos &&
fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7; (lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14; (lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2; 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 getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
fun save() {
StateSubscriptions.instance.saveSubscription(this);
}
fun saveAsync() {
StateSubscriptions.instance.saveSubscription(this);
}
fun updateChannel(channel: IPlatformChannel) { fun updateChannel(channel: IPlatformChannel) {
this.channel = SerializedChannel.fromChannel(channel); this.channel = SerializedChannel.fromChannel(channel);
} }
fun updatePlayback(content: IPlatformContentDetails, seconds: Int) {
playbackSeconds += seconds;
}
fun addPlaybackView() {
playbackViews += 1;
}
fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) { fun updateSubscriptionState(type: String, initialPage: List<IPlatformContent>) {
val interval: Int; val interval: Int;
val mostRecent: OffsetDateTime?; val mostRecent: OffsetDateTime?;
@@ -84,30 +106,39 @@ class Subscription {
else { else {
interval = 5; interval = 5;
mostRecent = null; mostRecent = null;
Logger.i("Subscription", "Subscription [${channel.name}]:${type} no results found");
} }
when(type) { when(type) {
ResultCapabilities.TYPE_VIDEOS -> { ResultCapabilities.TYPE_VIDEOS -> {
uploadInterval = interval; uploadInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastVideo = mostRecent; lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_MIXED -> { ResultCapabilities.TYPE_MIXED -> {
uploadInterval = interval; uploadInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastVideo = mostRecent; lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_STREAMS -> { ResultCapabilities.TYPE_STREAMS -> {
uploadStreamInterval = interval; uploadStreamInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastLiveStream = mostRecent; lastLiveStream = mostRecent;
else if(lastLiveStream.year > 3000)
lastLiveStream = OffsetDateTime.MIN;
lastStreamUpdate = OffsetDateTime.now(); lastStreamUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_POSTS -> { ResultCapabilities.TYPE_POSTS -> {
uploadPostInterval = interval; uploadPostInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastPost = mostRecent; lastPost = mostRecent;
else if(lastPost.year > 3000)
lastPost = OffsetDateTime.MIN;
lastPostUpdate = OffsetDateTime.now(); lastPostUpdate = OffsetDateTime.now();
} }
} }
@@ -452,12 +452,13 @@ class StateApp {
if(Settings.instance.subscriptions.fetchOnAppBoot) { if(Settings.instance.subscriptions.fetchOnAppBoot) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }; val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) { if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(8000); delay(5000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
} }
else else
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}"); Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
@@ -762,7 +762,7 @@ class StatePlatform {
} }
if(hasChanges) if(hasChanges)
StateSubscriptions.instance.saveSubscription(sub); sub.save();
} }
return pagerResult; return pagerResult;
@@ -374,7 +374,10 @@ class StatePlugins {
if(icon != null) if(icon != null)
iconsDir.saveIconBinary(config.id, icon); 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; return null;
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -32,12 +32,14 @@ import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithm
import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms import com.futo.platformplayer.subscription.SubscriptionFetchAlgorithms
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.time.OffsetDateTime
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.streams.toList
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
/*** /***
@@ -74,6 +76,13 @@ class StateSubscriptions {
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>(); 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> { fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal); return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
} }
@@ -170,6 +179,9 @@ class StateSubscriptions {
fun saveSubscription(sub: Subscription) { fun saveSubscription(sub: Subscription) {
_subscriptions.save(sub, false, true); _subscriptions.save(sub, false, true);
} }
fun saveSubscriptionAsync(sub: Subscription) {
_subscriptions.saveAsync(sub, false, true);
}
fun getSubscriptionCount(): Int { fun getSubscriptionCount(): Int {
synchronized(_subscriptions) { synchronized(_subscriptions) {
return _subscriptions.getItems().size; return _subscriptions.getItems().size;
@@ -242,13 +254,13 @@ class StateSubscriptions {
} }
val usePolycentric = false; val usePolycentric = true;
val subUrls = getSubscriptions().associateWith { val subUrls = getSubscriptions().parallelStream().map {
if(usePolycentric) if(usePolycentric)
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id); Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
else else
listOf(it.channel.url); Pair(it, listOf(it.channel.url));
}; }.toList().associate { it };
val result = algo.getSubscriptions(subUrls); val result = algo.getSubscriptions(subUrls);
return Pair(result.pager, result.exceptions); return Pair(result.pager, result.exceptions);
@@ -227,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; val scope = StateApp.instance.scopeOrNull;
if(scope != null) if(scope != null)
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
save(obj, withReconstruction); save(obj, withReconstruction, onlyExisting);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to save.", e); Logger.e(TAG, "Failed to save.", e);
} }
}; };
else else
save(obj, withReconstruction); save(obj, withReconstruction, onlyExisting);
} }
fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) { fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) {
val scope = StateApp.instance.scopeOrNull; val scope = StateApp.instance.scopeOrNull;
@@ -59,7 +59,7 @@ class SmartSubscriptionAlgorithm(
for(clientTasks in ordering) { for(clientTasks in ordering) {
val limit = clientTasks.first.config.subscriptionRateLimit; val limit = clientTasks.first.getSubscriptionRateLimit();
if(limit == null || limit <= 0) if(limit == null || limit <= 0)
finalTasks.addAll(clientTasks.second); finalTasks.addAll(clientTasks.second);
else { else {
@@ -85,21 +85,21 @@ class SmartSubscriptionAlgorithm(
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStream; ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStream;
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStream; ResultCapabilities.TYPE_LIVE -> sub.lastLiveStream;
ResultCapabilities.TYPE_POSTS -> sub.lastPost; ResultCapabilities.TYPE_POSTS -> sub.lastPost;
else -> sub.lastVideo; //TODO: minimum of all else -> sub.lastVideo; //TODO: minimum of all?
}; };
val lastUpdate = when(type) { val lastUpdate = when(type) {
ResultCapabilities.TYPE_VIDEOS -> sub.lastVideoUpdate; ResultCapabilities.TYPE_VIDEOS -> sub.lastVideoUpdate;
ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStreamUpdate; ResultCapabilities.TYPE_STREAMS -> sub.lastLiveStreamUpdate;
ResultCapabilities.TYPE_LIVE -> sub.lastLiveStreamUpdate; ResultCapabilities.TYPE_LIVE -> sub.lastLiveStreamUpdate;
ResultCapabilities.TYPE_POSTS -> sub.lastPostUpdate; ResultCapabilities.TYPE_POSTS -> sub.lastPostUpdate;
else -> sub.lastVideoUpdate; //TODO: minimum of all else -> sub.lastVideoUpdate; //TODO: minimum of all?
}; };
val interval = when(type) { val interval = when(type) {
ResultCapabilities.TYPE_VIDEOS -> sub.uploadInterval; ResultCapabilities.TYPE_VIDEOS -> sub.uploadInterval;
ResultCapabilities.TYPE_STREAMS -> sub.uploadStreamInterval; ResultCapabilities.TYPE_STREAMS -> sub.uploadStreamInterval;
ResultCapabilities.TYPE_LIVE -> sub.uploadStreamInterval; ResultCapabilities.TYPE_LIVE -> sub.uploadStreamInterval;
ResultCapabilities.TYPE_POSTS -> sub.uploadPostInterval; ResultCapabilities.TYPE_POSTS -> sub.uploadPostInterval;
else -> sub.uploadInterval; //TODO: minimum of all else -> sub.uploadInterval; //TODO: minimum of all?
}; };
val lastItemDaysAgo = lastItem.getNowDiffHours(); val lastItemDaysAgo = lastItem.getNowDiffHours();
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours(); val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
@@ -54,7 +54,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size; val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
val clientCacheCount = clientTasks.value.size - clientTaskCount; val clientCacheCount = clientTasks.value.size - clientTaskCount;
if(clientCacheCount > 0) { if(clientCacheCount > 0) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels.") UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)");
} }
} }
@@ -155,7 +155,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val initialPage = pager.getResults(); val initialPage = pager.getResults();
task.sub.updateSubscriptionState(task.type, initialPage); task.sub.updateSubscriptionState(task.type, initialPage);
StateSubscriptions.instance.saveSubscription(task.sub); task.sub.save();
finished++; finished++;
onProgress.emit(finished, forkTasks.size); onProgress.emit(finished, forkTasks.size);
@@ -14,7 +14,8 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
private val _confirmationMessage: String; private val _confirmationMessage: String;
var onClick = Event1<Subscription>(); var onClick = Event1<Subscription>();
var sortBy: Int = 0 var onSettings = Event1<Subscription>();
var sortBy: Int = 3
set(value) { set(value) {
field = value; field = value;
updateDataset(); updateDataset();
@@ -33,12 +34,16 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder { override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SubscriptionViewHolder {
val holder = SubscriptionViewHolder(viewGroup); val holder = SubscriptionViewHolder(viewGroup);
holder.onClick.subscribe(onClick::emit); holder.onClick.subscribe(onClick::emit);
holder.onSettings.subscribe(onSettings::emit);
holder.onTrash.subscribe { holder.onTrash.subscribe {
val sub = holder.subscription ?: return@subscribe; val sub = holder.subscription ?: return@subscribe;
UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, { UIDialogs.showConfirmationDialog(_inflater.context, _confirmationMessage, {
StateSubscriptions.instance.removeSubscription(sub.channel.url); StateSubscriptions.instance.removeSubscription(sub.channel.url);
}); });
}; };
holder.onSettings.subscribe {
onSettings.emit(it);
};
return holder; return holder;
} }
@@ -49,11 +54,20 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
private fun updateDataset() { private fun updateDataset() {
_sortedDataset = when (sortBy) { _sortedDataset = when (sortBy) {
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name }) 0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name.lowercase() })
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name }) 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."); else -> throw IllegalStateException("Invalid sorting algorithm selected.");
}.toList(); }.toList();
notifyDataSetChanged(); 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.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
import com.futo.platformplayer.R 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.states.StateApp
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.models.Subscription 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.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage 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.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.polycentric.core.toURLInfoSystemLinkUrl import com.futo.polycentric.core.toURLInfoSystemLinkUrl
@@ -26,12 +31,14 @@ class SubscriptionViewHolder : ViewHolder {
private val _textName: TextView; private val _textName: TextView;
private val _creatorThumbnail: CreatorThumbnail; private val _creatorThumbnail: CreatorThumbnail;
private val _buttonTrash: ImageButton; private val _buttonTrash: ImageButton;
private val _buttonSettings: ImageButton;
private val _platformIndicator : PlatformIndicator; private val _platformIndicator : PlatformIndicator;
private val _textMeta: TextView;
private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>( private val _taskLoadProfile = TaskHandler<PlatformID, PolycentricCache.CachedPolycentricProfile?>(
StateApp.instance.scopeGetter, StateApp.instance.scopeGetter,
{ PolycentricCache.instance.getProfileAsync(it) }) { PolycentricCache.instance.getProfileAsync(it) })
.success { it -> onProfileLoaded(it, true) } .success { it -> onProfileLoaded(null, it, true) }
.exception<Throwable> { .exception<Throwable> {
Logger.w(TAG, "Failed to load profile.", it); Logger.w(TAG, "Failed to load profile.", it);
}; };
@@ -41,12 +48,15 @@ class SubscriptionViewHolder : ViewHolder {
var onClick = Event1<Subscription>(); var onClick = Event1<Subscription>();
var onTrash = Event0(); var onTrash = Event0();
var onSettings = Event1<Subscription>();
constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) { constructor(viewGroup: ViewGroup) : super(LayoutInflater.from(viewGroup.context).inflate(R.layout.list_subscription, viewGroup, false)) {
_layoutSubscription = itemView.findViewById(R.id.layout_subscription); _layoutSubscription = itemView.findViewById(R.id.layout_subscription);
_textName = itemView.findViewById(R.id.text_name); _textName = itemView.findViewById(R.id.text_name);
_textMeta = itemView.findViewById(R.id.text_meta);
_creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail); _creatorThumbnail = itemView.findViewById(R.id.creator_thumbnail);
_buttonTrash = itemView.findViewById(R.id.button_trash); _buttonTrash = itemView.findViewById(R.id.button_trash);
_buttonSettings = itemView.findViewById(R.id.button_settings);
_platformIndicator = itemView.findViewById(R.id.platform); _platformIndicator = itemView.findViewById(R.id.platform);
_layoutSubscription.setOnClickListener { _layoutSubscription.setOnClickListener {
@@ -59,6 +69,11 @@ class SubscriptionViewHolder : ViewHolder {
_buttonTrash.setOnClickListener { _buttonTrash.setOnClickListener {
onTrash.emit(); onTrash.emit();
}; };
_buttonSettings.setOnClickListener {
subscription?.let {
onSettings.emit(it);
};
}
} }
fun bind(sub: Subscription) { fun bind(sub: Subscription) {
@@ -68,17 +83,18 @@ class SubscriptionViewHolder : ViewHolder {
val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true); val cachedProfile = PolycentricCache.instance.getCachedProfile(sub.channel.url, true);
if (cachedProfile != null) { if (cachedProfile != null) {
onProfileLoaded(cachedProfile, false); onProfileLoaded(sub, cachedProfile, false);
} else { } else {
_creatorThumbnail.setThumbnail(sub.channel.thumbnail, false); _creatorThumbnail.setThumbnail(sub.channel.thumbnail, false);
_taskLoadProfile.run(sub.channel.id); _taskLoadProfile.run(sub.channel.id);
_textName.text = sub.channel.name; _textName.text = sub.channel.name;
bindViewMetrics(sub);
} }
_platformIndicator.setPlatformFromClientID(sub.channel.id.pluginId); _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 dp_46 = 46.dp(itemView.context.resources);
val profile = cachedPolycentricProfile?.profile; val profile = cachedPolycentricProfile?.profile;
val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46) val avatar = profile?.systemState?.avatar?.selectBestImage(dp_46 * dp_46)
@@ -94,6 +110,19 @@ class SubscriptionViewHolder : ViewHolder {
if (profile != null) { if (profile != null) {
_textName.text = profile.systemState.username; _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 { companion object {
@@ -21,10 +21,13 @@ class SourceHeaderView : LinearLayout {
private val _sourceDescription: TextView; private val _sourceDescription: TextView;
private val _sourceVersion: TextView; private val _sourceVersion: TextView;
private val _sourcePlatformUrl: TextView;
private val _sourceRepositoryUrl: TextView; private val _sourceRepositoryUrl: TextView;
private val _sourceScriptUrl: TextView; private val _sourceScriptUrl: TextView;
private val _sourceSignature: TextView; private val _sourceSignature: TextView;
private val _sourcePlatformUrlContainer: LinearLayout;
private var _config : SourcePluginConfig? = null; private var _config : SourcePluginConfig? = null;
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
@@ -38,6 +41,8 @@ class SourceHeaderView : LinearLayout {
_sourceVersion = findViewById(R.id.source_version); _sourceVersion = findViewById(R.id.source_version);
_sourceRepositoryUrl = findViewById(R.id.source_repo); _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); _sourceScriptUrl = findViewById(R.id.source_script);
_sourceSignature = findViewById(R.id.source_signature); _sourceSignature = findViewById(R.id.source_signature);
@@ -53,6 +58,10 @@ class SourceHeaderView : LinearLayout {
if(!_config?.absoluteScriptUrl.isNullOrEmpty()) if(!_config?.absoluteScriptUrl.isNullOrEmpty())
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(_config?.absoluteScriptUrl))); 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?) { fun loadConfig(config: SourcePluginConfig, script: String?) {
@@ -74,6 +83,12 @@ class SourceHeaderView : LinearLayout {
_sourceRepositoryUrl.text = config.repositoryUrl; _sourceRepositoryUrl.text = config.repositoryUrl;
_sourceAuthorID.text = ""; _sourceAuthorID.text = "";
_sourcePlatformUrl.text = config.platformUrl ?: "";
if(!config.platformUrl.isNullOrEmpty())
_sourcePlatformUrlContainer.visibility = VISIBLE;
else
_sourcePlatformUrlContainer.visibility = GONE;
if(!config.authorUrl.isNullOrEmpty()) if(!config.authorUrl.isNullOrEmpty())
_sourceBy.setTextColor(resources.getColor(R.color.colorPrimary)); _sourceBy.setTextColor(resources.getColor(R.color.colorPrimary));
else else
@@ -105,5 +120,7 @@ class SourceHeaderView : LinearLayout {
_sourceScriptUrl.text = ""; _sourceScriptUrl.text = "";
_sourceRepositoryUrl.text = ""; _sourceRepositoryUrl.text = "";
_sourceAuthorID.text = ""; _sourceAuthorID.text = "";
_sourcePlatformUrl.text = "";
_sourcePlatformUrlContainer.visibility = GONE;
} }
} }
@@ -36,6 +36,7 @@ class SubscribeButton : LinearLayout {
} else { null }; } else { null };
val onSubscribed = Event1<Subscription>(); val onSubscribed = Event1<Subscription>();
val onUnSubscribed = Event1<String>();
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
@@ -82,6 +83,7 @@ class SubscribeButton : LinearLayout {
if (removed != null) if (removed != null)
UIDialogs.toast(context, context.getString(R.string.unsubscribed_from) + removed.channel.name); UIDialogs.toast(context, context.getString(R.string.unsubscribed_from) + removed.channel.name);
setIsSubscribed(false); setIsSubscribed(false);
onUnSubscribed.emit(url);
} }
fun setSubscribeChannel(url: String) { fun setSubscribeChannel(url: String) {
@@ -25,7 +25,7 @@ class SubscriptionBar : LinearLayout {
constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) { constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
inflate(context, R.layout.view_subscription_bar, this); 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) { _adapterView = findViewById<RecyclerView>(R.id.recycler_creators).asAny(subscriptions, orientation = RecyclerView.HORIZONTAL) {
it.onClick.subscribe { c -> it.onClick.subscribe { c ->
onClickChannel.emit(c.channel); onClickChannel.emit(c.channel);
@@ -55,4 +55,11 @@
android:clipToPadding="false" android:clipToPadding="false"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> 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> </androidx.coordinatorlayout.widget.CoordinatorLayout>
+37 -11
View File
@@ -35,19 +35,45 @@
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" /> android:ellipsize="end" />
<com.futo.platformplayer.views.platform.PlatformIndicator <LinearLayout
android:id="@+id/platform" android:layout_width="wrap_content"
android:layout_width="25dp" android:layout_height="wrap_content"
android:layout_height="25dp" /> 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> </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 <ImageButton
android:id="@+id/button_trash" android:id="@+id/button_trash"
android:layout_width="wrap_content" android:layout_width="60dp"
android:layout_height="wrap_content" android:layout_height="40dp"
app:srcCompat="@drawable/ic_trash_18dp" app:srcCompat="@drawable/ic_trash"
android:paddingStart="20dp" android:scaleType="fitCenter"
android:paddingTop="10dp" android:paddingStart="5dp"
android:paddingBottom="10dp" android:paddingTop="5dp"
android:paddingEnd="20dp" /> android:paddingBottom="5dp"
android:paddingEnd="5dp" />
</LinearLayout> </LinearLayout>
@@ -100,6 +100,30 @@
tools:text="3" /> tools:text="3" />
</LinearLayout> </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--> <!--Repo Url-->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
+15
View File
@@ -98,6 +98,7 @@
<string name="are_you_sure_delete_historical">Are you sure you want to remove these historical entries?</string> <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="removed">removed</string>
<string name="add_source">Add Source</string> <string name="add_source">Add Source</string>
<string name="platform_url">Platform URL</string>
<string name="repository_url">Repository URL</string> <string name="repository_url">Repository URL</string>
<string name="script_url">Script URL</string> <string name="script_url">Script URL</string>
<string name="source_permissions_explanation">These are the permissions the plugin requires to function</string> <string name="source_permissions_explanation">These are the permissions the plugin requires to function</string>
@@ -276,6 +277,8 @@
<string name="change_the_external_storage_for_download_files">Change the external storage for download files</string> <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">Clear Cookies</string>
<string name="clear_cookies_on_logout">Clear Cookies on Logout</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="clear_payment">Clear Payment</string>
<string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string> <string name="clears_cookies_when_you_log_out">Clears cookies when you log out</string>
<string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string> <string name="clears_in_app_browser_cookies">Clears in-app browser cookies</string>
@@ -339,6 +342,10 @@
<string name="submit_logs">Submit logs</string> <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="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="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="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="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> <string name="version_code">Version Code</string>
@@ -390,6 +397,10 @@
<string name="various_tests_against_a_custom_source">Various tests against a custom source</string> <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="writes_to_disk_till_no_space_is_left">Writes to disk till no space is left</string>
<string name="visibility">Visibility</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="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_home_tab">Show content in home tab</string>
<string name="show_content_in_search_results">Show content in search results</string> <string name="show_content_in_search_results">Show content in search results</string>
@@ -700,6 +711,10 @@
<string-array name="subscriptions_sortby_array"> <string-array name="subscriptions_sortby_array">
<item>Name Ascending</item> <item>Name Ascending</item>
<item>Name Descending</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>
<string-array name="feed_style"> <string-array name="feed_style">
<item>Preview</item> <item>Preview</item>