mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-18 22:12:35 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1896b7f2 | |||
| 88ca90c13a | |||
| f8ee340499 | |||
| 93f5260e20 |
@@ -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 {
|
||||
|
||||
@@ -68,6 +68,12 @@ fun ensureNotMainThread() {
|
||||
}
|
||||
}
|
||||
|
||||
private val _regexUrl = Regex("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&\\/\\/=]*)");
|
||||
fun String.isHttpUrl(): Boolean {
|
||||
return _regexUrl.matchEntire(this) != null;
|
||||
}
|
||||
|
||||
|
||||
private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
|
||||
fun String.isHexColor(): Boolean {
|
||||
return _regexHexColor.matches(this);
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.media.MediaSession2Service.MediaNotification
|
||||
import androidx.concurrent.futures.CallbackToFutureAdapter
|
||||
import androidx.concurrent.futures.ResolvableFuture
|
||||
@@ -11,8 +13,12 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.request.target.CustomTarget
|
||||
import com.bumptech.glide.request.transition.Transition
|
||||
import com.futo.platformplayer.activities.MainActivity
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.getNowDiffSeconds
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -29,10 +35,10 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class BackgroundWorker(private val appContext: Context, workerParams: WorkerParameters) :
|
||||
class BackgroundWorker(private val appContext: Context, private val workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
override suspend fun doWork(): Result {
|
||||
if(StateApp.instance.isMainActive) {
|
||||
if(StateApp.instance.isMainActive && !inputData.getBoolean("bypassMainCheck", false)) {
|
||||
Logger.i("BackgroundWorker", "CANCELLED");
|
||||
return Result.success();
|
||||
}
|
||||
@@ -86,9 +92,10 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
val newSubChanges = hashSetOf<Subscription>();
|
||||
val newItems = mutableListOf<IPlatformContent>();
|
||||
|
||||
val now = OffsetDateTime.now();
|
||||
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
|
||||
withContext(Dispatchers.IO) {
|
||||
StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
|
||||
Logger.i("BackgroundWorker", "SUBSCRIPTION PROGRESS: ${progress}/${total}");
|
||||
|
||||
synchronized(manager) {
|
||||
@@ -103,29 +110,46 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
synchronized(newSubChanges) {
|
||||
if(!newSubChanges.contains(sub)) {
|
||||
newSubChanges.add(sub);
|
||||
if(sub.doNotifications)
|
||||
if(sub.doNotifications && content.datetime?.let { it < now } == true)
|
||||
contentNotifs.add(Pair(sub, content));
|
||||
}
|
||||
newItems.add(content);
|
||||
}
|
||||
});
|
||||
|
||||
//Only for testing notifications
|
||||
val testNotifs = 0;
|
||||
if(contentNotifs.size == 0 && testNotifs > 0) {
|
||||
results.first.getResults().filter { it is IPlatformVideo && it.datetime?.let { it < now } == true }
|
||||
.take(testNotifs).forEach {
|
||||
contentNotifs.add(Pair(StateSubscriptions.instance.getSubscriptions().first(), it));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
manager.cancel(12);
|
||||
|
||||
if(newItems.size > 0) {
|
||||
if(contentNotifs.size > 0) {
|
||||
try {
|
||||
val items = contentNotifs.take(5).toList()
|
||||
for(i in items.indices) {
|
||||
val contentNotif = items.get(i);
|
||||
manager.notify(13 + i, NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New video by [${contentNotif.first.channel.name}]")
|
||||
.setContentText("${contentNotif.second.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, contentNotif.second.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id).build());
|
||||
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail()
|
||||
else null;
|
||||
if(thumbnail != null)
|
||||
Glide.with(appContext).asBitmap()
|
||||
.load(thumbnail)
|
||||
.into(object: CustomTarget<Bitmap>() {
|
||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
|
||||
}
|
||||
override fun onLoadCleared(placeholder: Drawable?) {}
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
})
|
||||
else
|
||||
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
|
||||
}
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
@@ -140,4 +164,20 @@ class BackgroundWorker(private val appContext: Context, workerParams: WorkerPara
|
||||
.setSilent(true)
|
||||
.setChannelId(notificationChannel.id).build());*/
|
||||
}
|
||||
|
||||
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
|
||||
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
|
||||
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
|
||||
.setContentTitle("New by [${sub.channel.name}]")
|
||||
.setContentText("${content.name}")
|
||||
.setSilent(true)
|
||||
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setChannelId(notificationChannel.id);
|
||||
if(thumbnail != null) {
|
||||
//notifBuilder.setLargeIcon(thumbnail);
|
||||
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
|
||||
}
|
||||
manager.notify(id, notifBuilder.build());
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,44 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.v2.ManagedStore
|
||||
import com.futo.platformplayer.toSafeFileName
|
||||
import com.futo.polycentric.core.toUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.toList
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class ChannelContentCache {
|
||||
private val _targetCacheSize = 3000;
|
||||
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
|
||||
val _channelContents = HashMap(_channelCacheDir.listFiles()
|
||||
.filter { it.isDirectory }
|
||||
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load()) });
|
||||
val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
|
||||
init {
|
||||
val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
|
||||
val initializeTime = measureTimeMillis {
|
||||
_channelContents = HashMap(allFiles
|
||||
.filter { it.isDirectory }
|
||||
.parallelStream().map {
|
||||
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
|
||||
.withoutBackup()
|
||||
.load())
|
||||
}.toList().associate { it })
|
||||
}
|
||||
val minDays = OffsetDateTime.now().minusDays(10);
|
||||
val totalItems = _channelContents.map { it.value.count() }.sum();
|
||||
val toTrim = totalItems - _targetCacheSize;
|
||||
val trimmed: Int;
|
||||
if(toTrim > 0) {
|
||||
val redundantContent = _channelContents.flatMap { it.value.getItems().filter { it.datetime != null && it.datetime!!.isBefore(minDays) }.drop(9) }
|
||||
.sortedBy { it.datetime!! }.take(toTrim);
|
||||
for(content in redundantContent)
|
||||
uncacheContent(content);
|
||||
trimmed = redundantContent.size;
|
||||
}
|
||||
else trimmed = 0;
|
||||
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
|
||||
}
|
||||
|
||||
fun getChannelCachePager(channelUrl: String): PlatformContentPager {
|
||||
val validID = channelUrl.toSafeFileName();
|
||||
@@ -38,7 +63,9 @@ class ChannelContentCache {
|
||||
return PlatformContentPager(items, Math.min(150, items.size));
|
||||
}
|
||||
fun getSubscriptionCachePager(): DedupContentPager {
|
||||
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
|
||||
val subs = StateSubscriptions.instance.getSubscriptions();
|
||||
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
|
||||
val allUrls = subs.map {
|
||||
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
|
||||
if(!otherUrls.contains(it.channel.url))
|
||||
@@ -46,6 +73,7 @@ class ChannelContentCache {
|
||||
else
|
||||
return@map otherUrls;
|
||||
}.flatten().distinct();
|
||||
Logger.i(TAG, "Subscriptions CachePager compiling");
|
||||
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
|
||||
|
||||
val validStores = _channelContents
|
||||
@@ -58,7 +86,11 @@ class ChannelContentCache {
|
||||
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
}
|
||||
|
||||
fun cacheVideos(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
fun uncacheContent(content: SerializedPlatformContent) {
|
||||
val store = getContentStore(content);
|
||||
store?.delete(content);
|
||||
}
|
||||
fun cacheContents(contents: List<IPlatformContent>): List<IPlatformContent> {
|
||||
return contents.filter { cacheContent(it) };
|
||||
}
|
||||
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
|
||||
@@ -66,14 +98,14 @@ class ChannelContentCache {
|
||||
return false;
|
||||
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
val store = synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
if(channelStore == null) {
|
||||
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}");
|
||||
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, channelStore);
|
||||
val store = getContentStore(channelId).let {
|
||||
if(it == null) {
|
||||
Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
|
||||
val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
|
||||
_channelContents.put(channelId, store);
|
||||
return@let store;
|
||||
}
|
||||
return@synchronized channelStore;
|
||||
else return@let it;
|
||||
}
|
||||
val serialized = SerializedPlatformContent.fromContent(content);
|
||||
val existing = store.findItems { it.url == content.url };
|
||||
@@ -88,6 +120,17 @@ class ChannelContentCache {
|
||||
return existing.isEmpty();
|
||||
}
|
||||
|
||||
private fun getContentStore(content: IPlatformContent): ManagedStore<SerializedPlatformContent>? {
|
||||
val channelId = content.author.url.toSafeFileName();
|
||||
return getContentStore(channelId);
|
||||
}
|
||||
private fun getContentStore(channelId: String): ManagedStore<SerializedPlatformContent>? {
|
||||
return synchronized(_channelContents) {
|
||||
var channelStore = _channelContents.get(channelId);
|
||||
return@synchronized channelStore;
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "ChannelCache";
|
||||
|
||||
@@ -95,10 +138,11 @@ class ChannelContentCache {
|
||||
private var _instance: ChannelContentCache? = null;
|
||||
val instance: ChannelContentCache get() {
|
||||
synchronized(_lock) {
|
||||
if(_instance == null)
|
||||
if(_instance == null) {
|
||||
_instance = ChannelContentCache();
|
||||
return _instance!!;
|
||||
}
|
||||
}
|
||||
return _instance!!;
|
||||
}
|
||||
|
||||
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
|
||||
@@ -114,7 +158,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
@@ -134,7 +178,7 @@ class ChannelContentCache {
|
||||
Logger.i(TAG, "Caching ${results.size} subscription results");
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val newCacheItems = instance.cacheVideos(results);
|
||||
val newCacheItems = instance.cacheContents(results);
|
||||
if(onNewCacheItem != null)
|
||||
newCacheItems.forEach { onNewCacheItem!!(it) }
|
||||
} catch (e: Throwable) {
|
||||
|
||||
+9
-2
@@ -25,6 +25,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.IRefreshPager
|
||||
import com.futo.platformplayer.api.media.structures.IReplacerPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
@@ -33,6 +34,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.FeedView
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter
|
||||
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
|
||||
@@ -74,9 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
|
||||
|
||||
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
|
||||
return@TaskHandler getContentPager(it);
|
||||
}).success {
|
||||
}).success { livePager ->
|
||||
setLoading(false);
|
||||
setPager(it);
|
||||
|
||||
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
|
||||
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
|
||||
else livePager;
|
||||
|
||||
setPager(pager);
|
||||
}
|
||||
.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
|
||||
import com.futo.platformplayer.isHttpUrl
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -143,7 +144,10 @@ class ContentSearchResultsFragment : MainFragment() {
|
||||
};
|
||||
|
||||
onSearch.subscribe(this) {
|
||||
setQuery(it, true);
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
setQuery(it, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
|
||||
_toolbarContentView = findViewById(R.id.container_toolbar_content);
|
||||
|
||||
var filteredNextPageCounter = 0;
|
||||
_nextPageHandler = TaskHandler<TPager, List<TResult>>({fragment.lifecycleScope}, {
|
||||
if (it is IAsyncPager<*>)
|
||||
it.nextPageAsync();
|
||||
@@ -141,7 +142,15 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
|
||||
val filteredResults = filterResults(it);
|
||||
recyclerData.results.addAll(filteredResults);
|
||||
recyclerData.resultsUnfiltered.addAll(it);
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
if(filteredResults.isEmpty()) {
|
||||
filteredNextPageCounter++
|
||||
if(filteredNextPageCounter <= 4)
|
||||
loadNextPage()
|
||||
}
|
||||
else {
|
||||
filteredNextPageCounter = 0;
|
||||
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
|
||||
}
|
||||
}.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load next page.", it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {
|
||||
|
||||
+20
-3
@@ -218,6 +218,13 @@ class SourceDetailFragment : MainFragment() {
|
||||
BigButtonGroup(c, context.getString(R.string.authentication),
|
||||
BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
|
||||
logoutSource();
|
||||
},
|
||||
BigButton(c, "Logout without Clear", "Logout but keep the browser cookies.\nThis allows for quick re-logging.", R.drawable.ic_logout) {
|
||||
logoutSource(false);
|
||||
}.apply {
|
||||
this.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -286,12 +293,22 @@ class SourceDetailFragment : MainFragment() {
|
||||
_sourceButtons.addView(group);
|
||||
}
|
||||
|
||||
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
|
||||
val advancedButtons = BigButtonGroup(c, "Advanced",
|
||||
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
|
||||
|
||||
}.apply {
|
||||
this.alpha = 0.5f;
|
||||
}
|
||||
},
|
||||
if(isEmbedded) BigButton(c, "Reinstall", "Modify the source of this plugin", R.drawable.ic_refresh) {
|
||||
StatePlugins.instance.updateEmbeddedPlugins(context, listOf(config.id), true);
|
||||
reloadSource(config.id);
|
||||
UIDialogs.toast(context, "Embedded plugin reinstalled, may require refresh");
|
||||
}.apply {
|
||||
this.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
setMargins(0, TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, resources.displayMetrics).toInt(), 0, 0);
|
||||
};
|
||||
} else null
|
||||
)
|
||||
|
||||
_sourceAdvancedButtons.removeAllViews();
|
||||
@@ -311,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
reloadSource(config.id);
|
||||
};
|
||||
}
|
||||
private fun logoutSource() {
|
||||
private fun logoutSource(clear: Boolean = true) {
|
||||
val config = _config ?: return;
|
||||
|
||||
StatePlugins.instance.setPluginAuth(config.id, null);
|
||||
@@ -319,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
|
||||
|
||||
|
||||
//TODO: Maybe add a dialog option..
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout) {
|
||||
if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
|
||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||
cookieManager.removeAllCookies(null);
|
||||
}
|
||||
|
||||
+20
-9
@@ -87,6 +87,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
@SuppressLint("ViewConstructor")
|
||||
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
|
||||
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
|
||||
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
@@ -110,13 +111,18 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
}
|
||||
|
||||
fun onShown() {
|
||||
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
|
||||
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
|
||||
setProgress(currentProgress.first, currentProgress.second);
|
||||
|
||||
if(recyclerData.loadedFeedStyle != feedStyle ||
|
||||
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
|
||||
recyclerData.lastLoad = OffsetDateTime.now();
|
||||
loadResults();
|
||||
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
loadResults(false);
|
||||
else if(recyclerData.results.size == 0)
|
||||
loadCache();
|
||||
}
|
||||
|
||||
val announcementsView = _announcementsView;
|
||||
@@ -176,6 +182,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
if(rateLimitPlugins.any())
|
||||
throw RateLimitException(rateLimitPlugins.map { it.key.id });
|
||||
}
|
||||
_bypassRateLimit = false;
|
||||
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
|
||||
|
||||
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
|
||||
@@ -198,7 +205,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
context.getString(R.string.rate_limit_warning), context.getString(R.string.this_is_a_temporary_measure_to_prevent_people_from_hitting_rate_limit_until_we_have_better_support_for_lots_of_subscriptions) + context.getString(R.string.you_have_too_many_subscriptions_for_the_following_plugins),
|
||||
subsByLimited.map { it.first.config.name + ": " + it.second.size + " " + context.getString(R.string.subscriptions) } .joinToString("\n"), 0, UIDialogs.Action("Refresh Anyway", {
|
||||
_bypassRateLimit = true;
|
||||
loadResults();
|
||||
loadResults(true);
|
||||
}, UIDialogs.ActionStyle.DANGEROUS_TEXT),
|
||||
UIDialogs.Action("OK", {
|
||||
finishRefreshLayoutLoader();
|
||||
@@ -210,7 +217,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, "Failed to load channel.", it);
|
||||
if(it !is CancellationException)
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults(true) });
|
||||
else {
|
||||
finishRefreshLayoutLoader();
|
||||
setLoading(false);
|
||||
@@ -275,15 +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) {
|
||||
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
|
||||
val results = cachePager.getResults();
|
||||
Logger.i(TAG, "Subscription show cache (${results.size})");
|
||||
setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
|
||||
setPager(cachePager);
|
||||
loadCache();
|
||||
} else {
|
||||
setTextCentered(null);
|
||||
}
|
||||
@@ -291,7 +302,7 @@ class SubscriptionsFeedFragment : MainFragment() {
|
||||
}
|
||||
|
||||
private fun loadedResult(pager: IPager<IPlatformContent>) {
|
||||
Logger.i(TAG, "Subscriptions new pager loaded");
|
||||
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
|
||||
|
||||
fragment.lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
|
||||
+4
-1
@@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
|
||||
} else if (_searchType == SearchType.PLAYLIST) {
|
||||
navigate<PlaylistSearchResultsFragment>(it);
|
||||
} else {
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
if(it.isHttpUrl())
|
||||
navigate<VideoDetailFragment>(it);
|
||||
else
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+87
-5
@@ -12,7 +12,6 @@ import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.provider.Browser
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.text.Spanned
|
||||
import android.util.AttributeSet
|
||||
@@ -23,7 +22,6 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -38,6 +36,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
|
||||
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
|
||||
import com.futo.platformplayer.api.media.models.chapters.ChapterType
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
|
||||
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
||||
@@ -65,6 +64,7 @@ import com.futo.platformplayer.engine.exceptions.ScriptUnavailableException
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.helpers.VideoHelper
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
||||
import com.futo.platformplayer.receivers.MediaControlReceiver
|
||||
import com.futo.platformplayer.states.*
|
||||
@@ -95,7 +95,6 @@ import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.ui.PlayerControlView
|
||||
import com.google.android.exoplayer2.ui.TimeBar
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException
|
||||
import com.google.common.base.Stopwatch
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.*
|
||||
import userpackage.Protocol
|
||||
@@ -173,6 +172,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
private val _addCommentView: AddCommentView;
|
||||
private val _toggleCommentType: Toggle;
|
||||
|
||||
private val _layoutSkip: LinearLayout;
|
||||
private val _textSkip: TextView;
|
||||
private val _textResume: TextView;
|
||||
private val _layoutResume: LinearLayout;
|
||||
private var _jobHideResume: Job? = null;
|
||||
@@ -296,6 +297,8 @@ class VideoDetailView : ConstraintLayout {
|
||||
_addCommentView = findViewById(R.id.add_comment_view);
|
||||
_commentsList = findViewById(R.id.comments_list);
|
||||
|
||||
_layoutSkip = findViewById(R.id.layout_skip);
|
||||
_textSkip = findViewById(R.id.text_skip);
|
||||
_layoutResume = findViewById(R.id.layout_resume);
|
||||
_textResume = findViewById(R.id.text_resume);
|
||||
_layoutPlayerContainer = findViewById(R.id.layout_player_container);
|
||||
@@ -403,6 +406,21 @@ class VideoDetailView : ConstraintLayout {
|
||||
_cast.onSettingsClick.subscribe { showVideoSettings() };
|
||||
_player.onVideoSettings.subscribe { showVideoSettings() };
|
||||
_player.onToggleFullScreen.subscribe(::handleFullScreen);
|
||||
_player.onChapterChanged.subscribe { chapter, isScrub ->
|
||||
if(_layoutSkip.visibility == VISIBLE && chapter?.type != ChapterType.SKIPPABLE)
|
||||
_layoutSkip.visibility = GONE;
|
||||
|
||||
if(!isScrub) {
|
||||
if(chapter?.type == ChapterType.SKIPPABLE) {
|
||||
_layoutSkip.visibility = VISIBLE;
|
||||
}
|
||||
else if(chapter?.type == ChapterType.SKIP) {
|
||||
_player.seekTo(chapter.timeEnd.toLong() * 1000);
|
||||
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_cast.onMinimizeClick.subscribe {
|
||||
_player.setFullScreen(false);
|
||||
onMinimize.emit();
|
||||
@@ -416,6 +434,7 @@ class VideoDetailView : ConstraintLayout {
|
||||
if (!_isCasting && !_didStop) {
|
||||
setLastPositionMilliseconds(position, true);
|
||||
}
|
||||
updatePlaybackTracking(position);
|
||||
};
|
||||
|
||||
_player.onVideoClicked.subscribe {
|
||||
@@ -581,6 +600,68 @@ class VideoDetailView : ConstraintLayout {
|
||||
|
||||
_layoutResume.visibility = View.GONE;
|
||||
};
|
||||
|
||||
_layoutSkip.setOnClickListener {
|
||||
val currentChapter = _player.getCurrentChapter(_player.position);
|
||||
if(currentChapter?.type == ChapterType.SKIPPABLE) {
|
||||
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val _trackingUpdateTimeLock = Object();
|
||||
val _trackingUpdateInterval = 3000;
|
||||
var _trackingLastUpdateTime = System.currentTimeMillis();
|
||||
var _trackingLastPosition: Long = 0;
|
||||
var _trackingLastVideo: IPlatformVideoDetails? = null;
|
||||
var _trackingTotalWatched: Long = 0;
|
||||
var _trackingDidCountView: Boolean = false;
|
||||
var _trackingLastVideoSubscription: Subscription? = null;
|
||||
fun updatePlaybackTracking(position: Long) {
|
||||
if(!Settings.instance.subscriptions.allowPlaytimeTracking)
|
||||
return;
|
||||
val now = System.currentTimeMillis();
|
||||
val shouldUpdate = synchronized(_trackingUpdateTimeLock) {
|
||||
val doUpdate = (now - _trackingLastUpdateTime) > _trackingUpdateInterval;
|
||||
if(doUpdate)
|
||||
_trackingLastUpdateTime = now;
|
||||
return@synchronized doUpdate;
|
||||
}
|
||||
if(shouldUpdate) {
|
||||
val currentVideo = video;
|
||||
val delta = position - _trackingLastPosition;
|
||||
_trackingLastPosition = position;
|
||||
|
||||
if(currentVideo != null && currentVideo == _trackingLastVideo) {
|
||||
if(delta > 500 && delta < _trackingUpdateInterval * 1.5) {
|
||||
_trackingLastVideoSubscription?.let {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] watch time delta [${delta}]" +
|
||||
"(${"%.2f".format((_trackingTotalWatched / 1000) / currentVideo.duration.toDouble().coerceAtLeast(1.0))})");
|
||||
it.updatePlayback(currentVideo, (delta / 1000).toInt());
|
||||
_trackingTotalWatched += delta;
|
||||
if(!_trackingDidCountView && currentVideo.duration > 0) {
|
||||
val percentage = (_trackingTotalWatched / 1000) / currentVideo.duration.toDouble();
|
||||
if(percentage > 0.4) {
|
||||
Logger.i(TAG, "Subscription [${it.channel.name}] new view");
|
||||
_trackingDidCountView = true;
|
||||
it.addPlaybackView();
|
||||
}
|
||||
}
|
||||
it.saveAsync();
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(_trackingLastVideo == null && currentVideo == null)
|
||||
return;
|
||||
_trackingLastVideo = currentVideo;
|
||||
_trackingTotalWatched = 0;
|
||||
if(currentVideo?.author?.url != null)
|
||||
_trackingLastVideoSubscription = StateSubscriptions.instance.getSubscription(currentVideo.author.url);
|
||||
else
|
||||
_trackingLastVideoSubscription = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMoreButtons() {
|
||||
@@ -961,9 +1042,10 @@ class VideoDetailView : ConstraintLayout {
|
||||
}
|
||||
catch(ex: Throwable) {
|
||||
Logger.e(TAG, "Failed to get chapters", ex);
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
/*withContext(Dispatchers.Main) {
|
||||
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
try {
|
||||
val stopwatch = com.futo.platformplayer.debug.Stopwatch()
|
||||
|
||||
@@ -4,9 +4,11 @@ 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.serializers.OffsetDateTimeSerializer
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@@ -43,6 +45,9 @@ class Subscription {
|
||||
var uploadStreamInterval : Int = 0;
|
||||
var uploadPostInterval : Int = 0;
|
||||
|
||||
var playbackSeconds: Int = 0;
|
||||
var playbackViews: Int = 0;
|
||||
|
||||
|
||||
constructor(channel : SerializedChannel) {
|
||||
this.channel = channel;
|
||||
@@ -55,10 +60,24 @@ class Subscription {
|
||||
|
||||
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?;
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
|
||||
import com.futo.platformplayer.background.BackgroundWorker
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
import com.futo.platformplayer.casting.StateCasting
|
||||
import com.futo.platformplayer.constructs.Event0
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
@@ -54,6 +55,8 @@ import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/***
|
||||
* This class contains global context for unconventional cases where obtaining context is hard.
|
||||
@@ -380,6 +383,18 @@ class StateApp {
|
||||
fun mainAppStarted(context: Context) {
|
||||
Logger.i(TAG, "App started");
|
||||
|
||||
//Start loading cache
|
||||
instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val time = measureTimeMillis {
|
||||
ChannelContentCache.instance;
|
||||
}
|
||||
Logger.i(TAG, "ChannelContentCache initialized in ${time}ms");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to load announcements.", e)
|
||||
}
|
||||
}
|
||||
|
||||
StateAnnouncement.instance.registerAnnouncement("fa4647d3-36fa-4c8c-832d-85b00fc72dca", "Disclaimer", "This is an early alpha build of the application, expect bugs and unfinished features.", AnnouncementType.DELETABLE, OffsetDateTime.now())
|
||||
|
||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
|
||||
@@ -442,7 +457,8 @@ class StateApp {
|
||||
if (isRateLimitReached) {
|
||||
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
|
||||
delay(5000);
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5)
|
||||
StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
|
||||
}
|
||||
else
|
||||
Logger.w(TAG, "Too many subscription requests required:\n${reqCountStr}");
|
||||
|
||||
@@ -762,7 +762,7 @@ class StatePlatform {
|
||||
}
|
||||
|
||||
if(hasChanges)
|
||||
StateSubscriptions.instance.saveSubscription(sub);
|
||||
sub.save();
|
||||
}
|
||||
|
||||
return pagerResult;
|
||||
|
||||
@@ -108,14 +108,12 @@ class StatePlugins {
|
||||
instance.deletePlugin(embedded.key);
|
||||
StatePlatform.instance.updateAvailableClients(context);
|
||||
}
|
||||
fun updateEmbeddedPlugins(context: Context) {
|
||||
for(embedded in getEmbeddedSources(context)) {
|
||||
fun updateEmbeddedPlugins(context: Context, subset: List<String>? = null, force: Boolean = false) {
|
||||
for(embedded in getEmbeddedSources(context).filter { subset == null || subset.contains(it.key) }) {
|
||||
val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value);
|
||||
if(FORCE_REINSTALL_EMBEDDED)
|
||||
deletePlugin(embedded.key);
|
||||
else if(embeddedConfig != null) {
|
||||
if(embeddedConfig != null) {
|
||||
val existing = getPlugin(embedded.key);
|
||||
if(existing != null && existing.config.version < embeddedConfig.version ) {
|
||||
if(existing != null && (existing.config.version < embeddedConfig.version || (force || FORCE_REINSTALL_EMBEDDED))) {
|
||||
Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
|
||||
//deletePlugin(embedded.key);
|
||||
installEmbeddedPlugin(context, embedded.value)
|
||||
|
||||
@@ -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,9 @@ class StateSubscriptions {
|
||||
|
||||
val onSubscriptionsChanged = Event2<List<Subscription>, Boolean>();
|
||||
|
||||
fun getOldestUpdateTime(): OffsetDateTime {
|
||||
return getSubscriptions().minOf { it.lastVideoUpdate };
|
||||
}
|
||||
fun getGlobalSubscriptionProgress(): Pair<Int, Int> {
|
||||
return Pair(_lastGlobalSubscriptionProgress, _lastGlobalSubscriptionTotal);
|
||||
}
|
||||
@@ -170,6 +175,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;
|
||||
@@ -242,9 +250,13 @@ class StateSubscriptions {
|
||||
|
||||
}
|
||||
|
||||
val subUrls = getSubscriptions().associateWith {
|
||||
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id)
|
||||
};
|
||||
val usePolycentric = true;
|
||||
val subUrls = getSubscriptions().parallelStream().map {
|
||||
if(usePolycentric)
|
||||
Pair(it, StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id));
|
||||
else
|
||||
Pair(it, listOf(it.channel.url));
|
||||
}.toList().associate { it };
|
||||
|
||||
val result = algo.getSubscriptions(subUrls);
|
||||
return Pair(result.pager, result.exceptions);
|
||||
|
||||
@@ -181,6 +181,12 @@ class ManagedStore<T>{
|
||||
return ReconstructionResult(successes, exs, builder.messages);
|
||||
}
|
||||
|
||||
|
||||
fun count(): Int {
|
||||
synchronized(_files) {
|
||||
return _files.size;
|
||||
}
|
||||
}
|
||||
fun getItems(): List<T> {
|
||||
synchronized(_files) {
|
||||
return _files.map { it.key };
|
||||
@@ -221,18 +227,18 @@ class ManagedStore<T>{
|
||||
}
|
||||
|
||||
|
||||
fun saveAsync(obj: T, withReconstruction: Boolean = false) {
|
||||
fun saveAsync(obj: T, withReconstruction: Boolean = false, onlyExisting: Boolean = false) {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
if(scope != null)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
save(obj, withReconstruction);
|
||||
save(obj, withReconstruction, onlyExisting);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to save.", e);
|
||||
}
|
||||
};
|
||||
else
|
||||
save(obj, withReconstruction);
|
||||
save(obj, withReconstruction, onlyExisting);
|
||||
}
|
||||
fun saveAllAsync(objs: List<T>, withReconstruction: Boolean = false) {
|
||||
val scope = StateApp.instance.scopeOrNull;
|
||||
|
||||
+5
-2
@@ -49,8 +49,11 @@ class SmartSubscriptionAlgorithm(
|
||||
};
|
||||
};
|
||||
|
||||
for(task in allTasks)
|
||||
task.urgency = calculateUpdateUrgency(task.sub, task.type);
|
||||
|
||||
val ordering = allTasks.groupBy { it.client }
|
||||
.map { Pair(it.key, it.value.sortedBy { calculateUpdateUrgency(it.sub, it.type) }) };
|
||||
.map { Pair(it.key, it.value.sortedBy { it.urgency }) };
|
||||
|
||||
val finalTasks = mutableListOf<SubscriptionTask>();
|
||||
|
||||
@@ -100,7 +103,7 @@ class SmartSubscriptionAlgorithm(
|
||||
};
|
||||
val lastItemDaysAgo = lastItem.getNowDiffHours();
|
||||
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
|
||||
val expectedHours = lastUpdateHoursAgo.toDouble() - (interval*24);
|
||||
val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble();
|
||||
|
||||
return (expectedHours * 100).toInt();
|
||||
}
|
||||
|
||||
+101
-49
@@ -7,6 +7,7 @@ import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.DedupContentPager
|
||||
import com.futo.platformplayer.api.media.structures.EmptyPager
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
|
||||
import com.futo.platformplayer.cache.ChannelContentCache
|
||||
@@ -42,24 +43,89 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
|
||||
val tasks = getSubscriptionTasks(subs);
|
||||
|
||||
val tasksGrouped = tasks.groupBy { it.client }
|
||||
val taskCount = tasks.filter { !it.fromCache }.size;
|
||||
val cacheCount = tasks.size - taskCount;
|
||||
Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
|
||||
" Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
|
||||
" Cached: ${tasks.filter { it.fromCache }.size}");
|
||||
" Tasks: ${taskCount}\n" +
|
||||
" Cached: ${cacheCount}");
|
||||
try {
|
||||
//TODO: Remove this
|
||||
UIDialogs.toast("Tasks: ${tasks.filter { !it.fromCache }.size}\n" +
|
||||
"Cached: ${tasks.filter { it.fromCache }.size}", false);
|
||||
for(clientTasks in tasksGrouped) {
|
||||
val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
|
||||
val clientCacheCount = clientTasks.value.size - clientTaskCount;
|
||||
if(clientCacheCount > 0) {
|
||||
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels. (${clientCacheCount} cached)");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (ex: Throwable){}
|
||||
|
||||
val exs: ArrayList<Throwable> = arrayListOf();
|
||||
val taskResults = arrayListOf<IPager<IPlatformContent>>();
|
||||
|
||||
val failedPlugins = mutableListOf<String>();
|
||||
val cachedChannels = mutableListOf<String>()
|
||||
val forkTasks = executeSubscriptionTasks(tasks, failedPlugins, cachedChannels);
|
||||
|
||||
|
||||
val taskResults = arrayListOf<SubscriptionTaskResult>();
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
val result = task.get();
|
||||
if(result != null) {
|
||||
if(result.pager != null)
|
||||
taskResults.add(result);
|
||||
else if(result.exception != null) {
|
||||
val ex = result.exception;
|
||||
if(ex != null) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex);
|
||||
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: ExecutionException) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex.cause);
|
||||
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
};
|
||||
}
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
|
||||
//Cache pagers grouped by channel
|
||||
val groupedPagers = taskResults.groupBy { it.task.sub.channel.url }
|
||||
.map { entry ->
|
||||
val sub = if(!entry.value.isEmpty()) entry.value[0].task.sub else null;
|
||||
val liveTasks = entry.value.filter { !it.task.fromCache };
|
||||
val cachedTasks = entry.value.filter { it.task.fromCache };
|
||||
val livePager = if(!liveTasks.isEmpty()) ChannelContentCache.cachePagerResults(scope, MultiChronoContentPager(liveTasks.map { it.pager!! }, true).apply { this.initialize() }, {
|
||||
onNewCacheHit.emit(sub!!, it);
|
||||
}) else null;
|
||||
val cachedPager = if(!cachedTasks.isEmpty()) MultiChronoContentPager(cachedTasks.map { it.pager!! }, true).apply { this.initialize() } else null;
|
||||
if(livePager != null && cachedPager == null)
|
||||
return@map livePager;
|
||||
else if(cachedPager != null && livePager == null)
|
||||
return@map cachedPager;
|
||||
else if(cachedPager == null && livePager == null)
|
||||
return@map EmptyPager();
|
||||
else
|
||||
return@map MultiChronoContentPager(listOf(livePager!!, cachedPager!!), true).apply { this.initialize() }
|
||||
}
|
||||
|
||||
val pager = MultiChronoContentPager(groupedPagers, allowFailure, 15);
|
||||
pager.initialize();
|
||||
|
||||
return Result(DedupContentPager(pager), exs);
|
||||
}
|
||||
|
||||
fun executeSubscriptionTasks(tasks: List<SubscriptionTask>, failedPlugins: MutableList<String>, cachedChannels: MutableList<String>): List<ForkJoinTask<SubscriptionTaskResult>> {
|
||||
val forkTasks = mutableListOf<ForkJoinTask<SubscriptionTaskResult>>();
|
||||
var finished = 0;
|
||||
val exceptionMap: HashMap<Subscription, Throwable> = hashMapOf();
|
||||
val concurrency = Settings.instance.subscriptions.getSubscriptionsConcurrency();
|
||||
val failedPlugins = arrayListOf<String>();
|
||||
val cachedChannels = arrayListOf<String>();
|
||||
|
||||
for(task in tasks) {
|
||||
val forkTask = threadPool.submit<SubscriptionTaskResult> {
|
||||
@@ -87,13 +153,9 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
pager = StatePlatform.instance.getChannelContent(task.client,
|
||||
task.url, task.type, ResultCapabilities.ORDER_CHONOLOGICAL);
|
||||
|
||||
pager = ChannelContentCache.cachePagerResults(scope, pager) {
|
||||
onNewCacheHit.emit(task.sub, it);
|
||||
};
|
||||
|
||||
val initialPage = pager.getResults();
|
||||
task.sub.updateSubscriptionState(task.type, initialPage);
|
||||
StateSubscriptions.instance.saveSubscription(task.sub);
|
||||
task.sub.save();
|
||||
|
||||
finished++;
|
||||
onProgress.emit(finished, forkTasks.size);
|
||||
@@ -105,6 +167,27 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
val channelEx = ChannelException(task.sub.channel, ex);
|
||||
finished++;
|
||||
onProgress.emit(finished, forkTasks.size);
|
||||
|
||||
|
||||
if(ex is ScriptCaptchaRequiredException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a captcha issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to Captcha");
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(ex is ScriptCriticalException) {
|
||||
synchronized(failedPlugins) {
|
||||
//Fail all subscription calls to plugin if it has a critical issue
|
||||
if(ex.config is SourcePluginConfig && !failedPlugins.contains(ex.config.id)) {
|
||||
Logger.w(StateSubscriptions.TAG, "Subscriptions ignoring plugin [${ex.config.name}] due to critical exception:\n" + ex.message);
|
||||
failedPlugins.add(ex.config.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!withCacheFallback)
|
||||
throw channelEx;
|
||||
else {
|
||||
@@ -117,39 +200,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
}
|
||||
forkTasks.add(forkTask);
|
||||
}
|
||||
|
||||
val timeTotal = measureTimeMillis {
|
||||
for(task in forkTasks) {
|
||||
try {
|
||||
val result = task.get();
|
||||
if(result != null) {
|
||||
if(result.pager != null)
|
||||
taskResults.add(result.pager!!);
|
||||
if(exceptionMap.containsKey(result.task.sub)) {
|
||||
val ex = exceptionMap[result.task.sub];
|
||||
if(ex != null) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex);
|
||||
if (nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: ExecutionException) {
|
||||
val nonRuntimeEx = findNonRuntimeException(ex.cause);
|
||||
if(nonRuntimeEx != null && (nonRuntimeEx is PluginException || nonRuntimeEx is ChannelException))
|
||||
exs.add(nonRuntimeEx);
|
||||
else
|
||||
throw ex.cause ?: ex;
|
||||
};
|
||||
}
|
||||
}
|
||||
Logger.i("StateSubscriptions", "Subscriptions results in ${timeTotal}ms")
|
||||
val pager = MultiChronoContentPager(taskResults, allowFailure, 15);
|
||||
pager.initialize();
|
||||
|
||||
return Result(DedupContentPager(pager), exs);
|
||||
return forkTasks;
|
||||
}
|
||||
|
||||
abstract fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask>;
|
||||
@@ -160,7 +211,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
|
||||
val sub: Subscription,
|
||||
val url: String,
|
||||
val type: String,
|
||||
var fromCache: Boolean = false
|
||||
var fromCache: Boolean = false,
|
||||
var urgency: Int = 0
|
||||
);
|
||||
|
||||
class SubscriptionTaskResult(
|
||||
|
||||
@@ -14,7 +14,7 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
private val _confirmationMessage: String;
|
||||
|
||||
var onClick = Event1<Subscription>();
|
||||
var sortBy: Int = 0
|
||||
var sortBy: Int = 3
|
||||
set(value) {
|
||||
field = value;
|
||||
updateDataset();
|
||||
@@ -51,6 +51,10 @@ class SubscriptionAdapter : RecyclerView.Adapter<SubscriptionViewHolder> {
|
||||
_sortedDataset = when (sortBy) {
|
||||
0 -> StateSubscriptions.instance.getSubscriptions().sortedBy({ u -> u.channel.name })
|
||||
1 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending({ u -> u.channel.name })
|
||||
2 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackViews }
|
||||
3 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackViews }
|
||||
4 -> StateSubscriptions.instance.getSubscriptions().sortedBy { it.playbackSeconds }
|
||||
5 -> StateSubscriptions.instance.getSubscriptions().sortedByDescending { it.playbackSeconds }
|
||||
else -> throw IllegalStateException("Invalid sorting algorithm selected.");
|
||||
}.toList();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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.states.StateApp
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.models.Subscription
|
||||
@@ -17,6 +18,8 @@ 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.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
|
||||
@@ -27,11 +30,12 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
private val _creatorThumbnail: CreatorThumbnail;
|
||||
private val _buttonTrash: 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);
|
||||
};
|
||||
@@ -45,6 +49,7 @@ class SubscriptionViewHolder : ViewHolder {
|
||||
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);
|
||||
_platformIndicator = itemView.findViewById(R.id.platform);
|
||||
@@ -68,17 +73,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 +100,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 {
|
||||
|
||||
@@ -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.playbackViews };
|
||||
_adapterView = findViewById<RecyclerView>(R.id.recycler_creators).asAny(subscriptions, orientation = RecyclerView.HORIZONTAL) {
|
||||
it.onClick.subscribe { c ->
|
||||
onClickChannel.emit(c.channel);
|
||||
|
||||
@@ -100,6 +100,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
val onSourceChanged = Event3<IVideoSource?, IAudioSource?, Boolean>();
|
||||
val onSourceEnded = Event0();
|
||||
|
||||
val onChapterChanged = Event2<IChapter?, Boolean>();
|
||||
|
||||
val onVideoClicked = Event0();
|
||||
val onTimeBarChanged = Event2<Long, Long>();
|
||||
|
||||
@@ -185,6 +187,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) {
|
||||
gestureControl.restartHideJob();
|
||||
|
||||
updateCurrentChapter(position);
|
||||
}
|
||||
|
||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||
@@ -233,17 +237,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
val delta = position - lastPos;
|
||||
if(delta > 1000 || delta < 0) {
|
||||
lastPos = position;
|
||||
val currentChapter = getCurrentChapter(position)
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
if (currentChapter != null) {
|
||||
_control_chapter.text = " • " + currentChapter.name;
|
||||
_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
} else {
|
||||
_control_chapter.text = "";
|
||||
_control_chapter_fullscreen.text = "";
|
||||
}
|
||||
}
|
||||
updateCurrentChapter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +250,22 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
||||
exoPlayer?.attach(_videoView, PLAYER_STATE_NAME);
|
||||
}
|
||||
|
||||
fun updateCurrentChapter(pos: Long? = null) {
|
||||
val chaptPos = pos ?: position;
|
||||
val currentChapter = getCurrentChapter(chaptPos);
|
||||
if(_currentChapter != currentChapter) {
|
||||
_currentChapter = currentChapter;
|
||||
if (currentChapter != null) {
|
||||
_control_chapter.text = " • " + currentChapter.name;
|
||||
_control_chapter_fullscreen.text = " • " + currentChapter.name;
|
||||
} else {
|
||||
_control_chapter.text = "";
|
||||
_control_chapter_fullscreen.text = "";
|
||||
}
|
||||
onChapterChanged.emit(currentChapter, pos != null);
|
||||
}
|
||||
}
|
||||
|
||||
fun setArtwork(drawable: Drawable?) {
|
||||
if (drawable != null) {
|
||||
_videoView.defaultArtwork = drawable;
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:background="@drawable/background_big_button"
|
||||
@@ -26,6 +24,8 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/button_text"
|
||||
@@ -47,7 +47,7 @@
|
||||
android:textSize="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:fontFamily="@font/inter_extra_light"
|
||||
android:maxLines="1"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end"
|
||||
tools:text="Attempts to fetch your subscriptions from this source" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -150,6 +150,30 @@
|
||||
android:textSize="12dp"
|
||||
android:fontFamily="@font/inter_light" />
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_skip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="5dp"
|
||||
android:background="@drawable/background_button_transparent_round"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_skip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Skip"
|
||||
android:textSize="12dp"
|
||||
android:fontFamily="@font/inter_light" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/contentContainer"
|
||||
|
||||
@@ -35,10 +35,25 @@
|
||||
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
|
||||
|
||||
@@ -276,6 +276,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 +341,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>
|
||||
@@ -700,6 +706,10 @@
|
||||
<string-array name="subscriptions_sortby_array">
|
||||
<item>Name Ascending</item>
|
||||
<item>Name Descending</item>
|
||||
<item>Views Ascending</item>
|
||||
<item>Views Descending</item>
|
||||
<item>Watchtime Ascending</item>
|
||||
<item>Watchtime Descending</item>
|
||||
</string-array>
|
||||
<string-array name="feed_style">
|
||||
<item>Preview</item>
|
||||
|
||||
Submodule app/src/stable/assets/sources/youtube updated: a1d432865e...ce7d9d0bc7
Submodule app/src/unstable/assets/sources/youtube updated: 5011bfcddb...ce7d9d0bc7
Reference in New Issue
Block a user