Compare commits

...

12 Commits

50 changed files with 508 additions and 109 deletions
@@ -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)
@@ -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;
@@ -92,7 +92,7 @@ class UISlideOverlays {
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
StateSubscriptions.instance.saveSubscription(subscription);
subscription.save();
menu.hide(true);
};
menu.onCancel.subscribe {
@@ -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]!!);
}
@@ -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);
@@ -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());
}
}
@@ -18,10 +18,11 @@ 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 = 2000;
private val _targetCacheSize = 3000;
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
init {
@@ -29,11 +30,11 @@ class ChannelContentCache {
val initializeTime = measureTimeMillis {
_channelContents = HashMap(allFiles
.filter { it.isDirectory }
.associate {
.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();
@@ -41,7 +42,7 @@ class ChannelContentCache {
val trimmed: Int;
if(toTrim > 0) {
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)
uncacheContent(content);
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.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> {
@@ -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 {
@@ -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,10 +142,15 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it);
if(filteredResults.isEmpty())
loadNextPage()
else
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, {
@@ -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 = 90;
fun newInstance() = ImportSubscriptionsFragment().apply {}
}
}
@@ -118,7 +118,11 @@ class SubscriptionsFeedFragment : MainFragment() {
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;
@@ -172,9 +176,9 @@ 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 });
}
@@ -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),
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();
@@ -213,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);
@@ -278,16 +282,19 @@ 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) {
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);
loadCache();
} else {
setTextCentered(null);
}
@@ -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
@@ -66,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.*
@@ -96,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
@@ -436,6 +434,7 @@ class VideoDetailView : ConstraintLayout {
if (!_isCasting && !_didStop) {
setLastPositionMilliseconds(position, true);
}
updatePlaybackTracking(position);
};
_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() {
val buttons = listOf(RoundButton(context, R.drawable.ic_add, context.getString(R.string.add), TAG_ADD) {
(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.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();
}
}
@@ -452,12 +452,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(8000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
delay(5000);
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;
@@ -374,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,13 +254,13 @@ class StateSubscriptions {
}
val usePolycentric = false;
val subUrls = getSubscriptions().associateWith {
val usePolycentric = true;
val subUrls = getSubscriptions().parallelStream().map {
if(usePolycentric)
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id);
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
else
listOf(it.channel.url);
};
Pair(it, listOf(it.channel.url));
}.toList().associate { it };
val result = algo.getSubscriptions(subUrls);
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;
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;
@@ -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(
@@ -59,7 +59,7 @@ class SmartSubscriptionAlgorithm(
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 {
@@ -85,21 +85,21 @@ 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();
@@ -16,6 +16,7 @@ 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.StatePlatform
@@ -46,15 +47,16 @@ abstract class SubscriptionsTaskFetchAlgorithm(
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: ${taskCount}\n" +
" Cached: ${cacheCount}");
tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } })" }.joinToString("\n"));
try {
for(clientTasks in tasksGrouped) {
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
val clientCacheCount = clientTasks.value.size - clientTaskCount;
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 +157,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val initialPage = pager.getResults();
task.sub.updateSubscriptionState(task.type, initialPage);
StateSubscriptions.instance.saveSubscription(task.sub);
task.sub.save();
finished++;
onProgress.emit(finished, forkTasks.size);
@@ -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);
@@ -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>
+37 -11
View File
@@ -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"
+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="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 +277,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>
@@ -339,6 +342,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 +397,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>
@@ -700,6 +711,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>