Working smart subscriptions, Direct url through search, channel content cache trimming, skippable and skip chapter support, reinstall button for embedded plugins

This commit is contained in:
Kelvin 2023-11-01 20:32:51 +01:00
parent 34ba44ffa4
commit 93f5260e20
18 changed files with 322 additions and 102 deletions

View File

@ -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})"); private val _regexHexColor = Regex("(#[a-fA-F0-9]{8})|(#[a-fA-F0-9]{6})|(#[a-fA-F0-9]{3})");
fun String.isHexColor(): Boolean { fun String.isHexColor(): Boolean {
return _regexHexColor.matches(this); return _regexHexColor.matches(this);

View File

@ -12,19 +12,43 @@ import com.futo.platformplayer.serializers.PlatformContentSerializer
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.v2.ManagedStore
import com.futo.platformplayer.toSafeFileName import com.futo.platformplayer.toSafeFileName
import com.futo.polycentric.core.toUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import kotlin.system.measureTimeMillis
class ChannelContentCache { class ChannelContentCache {
private val _targetCacheSize = 2000;
val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache"); val _channelCacheDir = FragmentedStorage.getOrCreateDirectory("channelCache");
val _channelContents = HashMap(_channelCacheDir.listFiles() val _channelContents: HashMap<String, ManagedStore<SerializedPlatformContent>>;
.filter { it.isDirectory } init {
.associate { Pair(it.name, FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, it.name, PlatformContentSerializer()) val allFiles = _channelCacheDir.listFiles() ?: arrayOf();
.withoutBackup() val initializeTime = measureTimeMillis {
.load()) }); _channelContents = HashMap(allFiles
.filter { it.isDirectory }
.associate {
Pair(it.name, FragmentedStorage.storeJson(_channelCacheDir, it.name, PlatformContentSerializer())
.withoutBackup()
.load())
});
}
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) }
.sortedByDescending { 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 { fun getChannelCachePager(channelUrl: String): PlatformContentPager {
val validID = channelUrl.toSafeFileName(); val validID = channelUrl.toSafeFileName();
@ -38,7 +62,9 @@ class ChannelContentCache {
return PlatformContentPager(items, Math.min(150, items.size)); return PlatformContentPager(items, Math.min(150, items.size));
} }
fun getSubscriptionCachePager(): DedupContentPager { fun getSubscriptionCachePager(): DedupContentPager {
Logger.i(TAG, "Subscriptions CachePager get subscriptions");
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
Logger.i(TAG, "Subscriptions CachePager polycentric urls");
val allUrls = subs.map { val allUrls = subs.map {
val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf(); val otherUrls = PolycentricCache.instance.getCachedProfile(it.channel.url)?.profile?.ownedClaims?.mapNotNull { c -> c.claim.resolveChannelUrl() } ?: listOf();
if(!otherUrls.contains(it.channel.url)) if(!otherUrls.contains(it.channel.url))
@ -46,6 +72,7 @@ class ChannelContentCache {
else else
return@map otherUrls; return@map otherUrls;
}.flatten().distinct(); }.flatten().distinct();
Logger.i(TAG, "Subscriptions CachePager compiling");
val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet(); val validSubIds = allUrls.map { it.toSafeFileName() }.toHashSet();
val validStores = _channelContents val validStores = _channelContents
@ -58,7 +85,11 @@ class ChannelContentCache {
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); 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) }; return contents.filter { cacheContent(it) };
} }
fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean { fun cacheContent(content: IPlatformContent, doUpdate: Boolean = false): Boolean {
@ -66,14 +97,14 @@ class ChannelContentCache {
return false; return false;
val channelId = content.author.url.toSafeFileName(); val channelId = content.author.url.toSafeFileName();
val store = synchronized(_channelContents) { val store = getContentStore(channelId).let {
var channelStore = _channelContents.get(channelId); if(it == null) {
if(channelStore == null) { Logger.i(TAG, "New Channel Cache for channel ${content.author.name}");
Logger.i(TAG, "New Subscription Cache for channel ${content.author.name}"); val store = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load();
channelStore = FragmentedStorage.storeJson<SerializedPlatformContent>(_channelCacheDir, channelId, PlatformContentSerializer()).load(); _channelContents.put(channelId, store);
_channelContents.put(channelId, channelStore); return@let store;
} }
return@synchronized channelStore; else return@let it;
} }
val serialized = SerializedPlatformContent.fromContent(content); val serialized = SerializedPlatformContent.fromContent(content);
val existing = store.findItems { it.url == content.url }; val existing = store.findItems { it.url == content.url };
@ -88,6 +119,17 @@ class ChannelContentCache {
return existing.isEmpty(); 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 { companion object {
private val TAG = "ChannelCache"; private val TAG = "ChannelCache";
@ -95,10 +137,11 @@ class ChannelContentCache {
private var _instance: ChannelContentCache? = null; private var _instance: ChannelContentCache? = null;
val instance: ChannelContentCache get() { val instance: ChannelContentCache get() {
synchronized(_lock) { synchronized(_lock) {
if(_instance == null) if(_instance == null) {
_instance = ChannelContentCache(); _instance = ChannelContentCache();
return _instance!!; }
} }
return _instance!!;
} }
fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> { fun cachePagerResults(scope: CoroutineScope, pager: IPager<IPlatformContent>, onNewCacheHit: ((IPlatformContent)->Unit)? = null): IPager<IPlatformContent> {
@ -114,7 +157,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]"); Logger.i(TAG, "Caching ${results.size} subscription initial results [${pager.hashCode()}]");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {
@ -134,7 +177,7 @@ class ChannelContentCache {
Logger.i(TAG, "Caching ${results.size} subscription results"); Logger.i(TAG, "Caching ${results.size} subscription results");
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
val newCacheItems = instance.cacheVideos(results); val newCacheItems = instance.cacheContents(results);
if(onNewCacheItem != null) if(onNewCacheItem != null)
newCacheItems.forEach { onNewCacheItem!!(it) } newCacheItems.forEach { onNewCacheItem!!(it) }
} catch (e: Throwable) { } catch (e: Throwable) {

View File

@ -17,6 +17,7 @@ import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.SearchTopBarFragment
import com.futo.platformplayer.isHttpUrl
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -143,7 +144,10 @@ class ContentSearchResultsFragment : MainFragment() {
}; };
onSearch.subscribe(this) { onSearch.subscribe(this) {
setQuery(it, true); if(it.isHttpUrl())
navigate<VideoDetailFragment>(it);
else
setQuery(it, true);
}; };
} }
} }

View File

@ -141,7 +141,10 @@ abstract class FeedView<TFragment, TResult, TConverted, TPager, TViewHolder> : L
val filteredResults = filterResults(it); val filteredResults = filterResults(it);
recyclerData.results.addAll(filteredResults); recyclerData.results.addAll(filteredResults);
recyclerData.resultsUnfiltered.addAll(it); recyclerData.resultsUnfiltered.addAll(it);
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size); if(filteredResults.isEmpty())
loadNextPage()
else
recyclerData.adapter.notifyItemRangeInserted(recyclerData.adapter.childToParentPosition(posBefore), filteredResults.size);
}.exception<Throwable> { }.exception<Throwable> {
Logger.w(TAG, "Failed to load next page.", it); Logger.w(TAG, "Failed to load next page.", it);
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, { UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_next_page), it, {

View File

@ -218,6 +218,13 @@ class SourceDetailFragment : MainFragment() {
BigButtonGroup(c, context.getString(R.string.authentication), 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) { BigButton(c, context.getString(R.string.logout), context.getString(R.string.sign_out_of_the_platform), R.drawable.ic_logout) {
logoutSource(); 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); _sourceButtons.addView(group);
} }
val isEmbedded = StatePlugins.instance.getEmbeddedSources(context).any { it.key == config.id };
val advancedButtons = BigButtonGroup(c, "Advanced", val advancedButtons = BigButtonGroup(c, "Advanced",
BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) { BigButton(c, "Edit Code", "Modify the source of this plugin", R.drawable.ic_code) {
}.apply { }.apply {
this.alpha = 0.5f; 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(); _sourceAdvancedButtons.removeAllViews();
@ -311,7 +328,7 @@ class SourceDetailFragment : MainFragment() {
reloadSource(config.id); reloadSource(config.id);
}; };
} }
private fun logoutSource() { private fun logoutSource(clear: Boolean = true) {
val config = _config ?: return; val config = _config ?: return;
StatePlugins.instance.setPluginAuth(config.id, null); StatePlugins.instance.setPluginAuth(config.id, null);
@ -319,7 +336,7 @@ class SourceDetailFragment : MainFragment() {
//TODO: Maybe add a dialog option.. //TODO: Maybe add a dialog option..
if(Settings.instance.plugins.clearCookiesOnLogout) { if(Settings.instance.plugins.clearCookiesOnLogout && clear) {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }

View File

@ -87,6 +87,7 @@ class SubscriptionsFeedFragment : MainFragment() {
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> { 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) { 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 -> StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
fragment.lifecycleScope.launch(Dispatchers.Main) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {
@ -110,6 +111,7 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
fun onShown() { fun onShown() {
Logger.i(TAG, "SubscriptionsFeedFragment onShown()");
val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress(); val currentProgress = StateSubscriptions.instance.getGlobalSubscriptionProgress();
setProgress(currentProgress.first, currentProgress.second); setProgress(currentProgress.first, currentProgress.second);
@ -176,6 +178,7 @@ class SubscriptionsFeedFragment : MainFragment() {
if(rateLimitPlugins.any()) if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id }); throw RateLimitException(rateLimitPlugins.map { it.key.id });
} }
_bypassRateLimit = false;
val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh); val resp = StateSubscriptions.instance.getGlobalSubscriptionFeed(StateApp.instance.scope, withRefresh);
val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions; val currentExs = StateSubscriptions.instance.globalSubscriptionExceptions;
@ -279,9 +282,10 @@ class SubscriptionsFeedFragment : MainFragment() {
setLoading(true); setLoading(true);
Logger.i(TAG, "Subscriptions load"); Logger.i(TAG, "Subscriptions load");
if(recyclerData.results.size == 0) { if(recyclerData.results.size == 0) {
Logger.i(TAG, "Subscriptions load cache");
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
val results = cachePager.getResults(); val results = cachePager.getResults();
Logger.i(TAG, "Subscription show cache (${results.size})"); 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); setTextCentered(if (results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
setPager(cachePager); setPager(cachePager);
} else { } else {
@ -291,7 +295,7 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
private fun loadedResult(pager: IPager<IPlatformContent>) { 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) { fragment.lifecycleScope.launch(Dispatchers.Main) {
try { try {

View File

@ -117,7 +117,10 @@ class SuggestionsFragment : MainFragment {
} else if (_searchType == SearchType.PLAYLIST) { } else if (_searchType == SearchType.PLAYLIST) {
navigate<PlaylistSearchResultsFragment>(it); navigate<PlaylistSearchResultsFragment>(it);
} else { } else {
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl)); if(it.isHttpUrl())
navigate<VideoDetailFragment>(it);
else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, SearchType.VIDEO, _channelUrl));
} }
}; };

View File

@ -38,6 +38,7 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException 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.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
@ -173,6 +174,8 @@ class VideoDetailView : ConstraintLayout {
private val _addCommentView: AddCommentView; private val _addCommentView: AddCommentView;
private val _toggleCommentType: Toggle; private val _toggleCommentType: Toggle;
private val _layoutSkip: LinearLayout;
private val _textSkip: TextView;
private val _textResume: TextView; private val _textResume: TextView;
private val _layoutResume: LinearLayout; private val _layoutResume: LinearLayout;
private var _jobHideResume: Job? = null; private var _jobHideResume: Job? = null;
@ -296,6 +299,8 @@ class VideoDetailView : ConstraintLayout {
_addCommentView = findViewById(R.id.add_comment_view); _addCommentView = findViewById(R.id.add_comment_view);
_commentsList = findViewById(R.id.comments_list); _commentsList = findViewById(R.id.comments_list);
_layoutSkip = findViewById(R.id.layout_skip);
_textSkip = findViewById(R.id.text_skip);
_layoutResume = findViewById(R.id.layout_resume); _layoutResume = findViewById(R.id.layout_resume);
_textResume = findViewById(R.id.text_resume); _textResume = findViewById(R.id.text_resume);
_layoutPlayerContainer = findViewById(R.id.layout_player_container); _layoutPlayerContainer = findViewById(R.id.layout_player_container);
@ -403,6 +408,21 @@ class VideoDetailView : ConstraintLayout {
_cast.onSettingsClick.subscribe { showVideoSettings() }; _cast.onSettingsClick.subscribe { showVideoSettings() };
_player.onVideoSettings.subscribe { showVideoSettings() }; _player.onVideoSettings.subscribe { showVideoSettings() };
_player.onToggleFullScreen.subscribe(::handleFullScreen); _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 { _cast.onMinimizeClick.subscribe {
_player.setFullScreen(false); _player.setFullScreen(false);
onMinimize.emit(); onMinimize.emit();
@ -581,6 +601,13 @@ class VideoDetailView : ConstraintLayout {
_layoutResume.visibility = View.GONE; _layoutResume.visibility = View.GONE;
}; };
_layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo(currentChapter.timeEnd.toLong() * 1000);
}
}
} }
fun updateMoreButtons() { fun updateMoreButtons() {
@ -961,9 +988,10 @@ class VideoDetailView : ConstraintLayout {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex); Logger.e(TAG, "Failed to get chapters", ex);
withContext(Dispatchers.Main) {
/*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
} }*/
} }
try { try {
val stopwatch = com.futo.platformplayer.debug.Stopwatch() val stopwatch = com.futo.platformplayer.debug.Stopwatch()

View File

@ -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.JSClient
import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.casting.StateCasting import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
@ -54,6 +55,8 @@ import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit 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. * This class contains global context for unconventional cases where obtaining context is hard.
@ -380,6 +383,18 @@ class StateApp {
fun mainAppStarted(context: Context) { fun mainAppStarted(context: Context) {
Logger.i(TAG, "App started"); 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()) 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) if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
@ -441,7 +456,7 @@ class StateApp {
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }; val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) { if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000); delay(8000);
StateSubscriptions.instance.updateSubscriptionFeed(scope, false); StateSubscriptions.instance.updateSubscriptionFeed(scope, false);
} }
else else

View File

@ -108,14 +108,12 @@ class StatePlugins {
instance.deletePlugin(embedded.key); instance.deletePlugin(embedded.key);
StatePlatform.instance.updateAvailableClients(context); StatePlatform.instance.updateAvailableClients(context);
} }
fun updateEmbeddedPlugins(context: Context) { fun updateEmbeddedPlugins(context: Context, subset: List<String>? = null, force: Boolean = false) {
for(embedded in getEmbeddedSources(context)) { for(embedded in getEmbeddedSources(context).filter { subset == null || subset.contains(it.key) }) {
val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value); val embeddedConfig = getEmbeddedPluginConfig(context, embedded.value);
if(FORCE_REINSTALL_EMBEDDED) if(embeddedConfig != null) {
deletePlugin(embedded.key);
else if(embeddedConfig != null) {
val existing = getPlugin(embedded.key); 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"); Logger.i(TAG, "Outdated Embedded plugin [${existing.config.id}] ${existing.config.name} (${existing.config.version} < ${embeddedConfig?.version}), reinstalling");
//deletePlugin(embedded.key); //deletePlugin(embedded.key);
installEmbeddedPlugin(context, embedded.value) installEmbeddedPlugin(context, embedded.value)

View File

@ -242,8 +242,12 @@ class StateSubscriptions {
} }
val usePolycentric = false;
val subUrls = getSubscriptions().associateWith { val subUrls = getSubscriptions().associateWith {
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id) if(usePolycentric)
StatePolycentric.instance.getChannelUrls(it.channel.url, it.channel.id);
else
listOf(it.channel.url);
}; };
val result = algo.getSubscriptions(subUrls); val result = algo.getSubscriptions(subUrls);

View File

@ -181,6 +181,12 @@ class ManagedStore<T>{
return ReconstructionResult(successes, exs, builder.messages); return ReconstructionResult(successes, exs, builder.messages);
} }
fun count(): Int {
synchronized(_files) {
return _files.size;
}
}
fun getItems(): List<T> { fun getItems(): List<T> {
synchronized(_files) { synchronized(_files) {
return _files.map { it.key }; return _files.map { it.key };

View File

@ -49,8 +49,11 @@ class SmartSubscriptionAlgorithm(
}; };
}; };
for(task in allTasks)
task.urgency = calculateUpdateUrgency(task.sub, task.type);
val ordering = allTasks.groupBy { it.client } 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>(); val finalTasks = mutableListOf<SubscriptionTask>();
@ -100,7 +103,7 @@ class SmartSubscriptionAlgorithm(
}; };
val lastItemDaysAgo = lastItem.getNowDiffHours(); val lastItemDaysAgo = lastItem.getNowDiffHours();
val lastUpdateHoursAgo = lastUpdate.getNowDiffHours(); val lastUpdateHoursAgo = lastUpdate.getNowDiffHours();
val expectedHours = lastUpdateHoursAgo.toDouble() - (interval*24); val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble();
return (expectedHours * 100).toInt(); return (expectedHours * 100).toInt();
} }

View File

@ -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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager 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.IPager
import com.futo.platformplayer.api.media.structures.MultiChronoContentPager import com.futo.platformplayer.api.media.structures.MultiChronoContentPager
import com.futo.platformplayer.cache.ChannelContentCache import com.futo.platformplayer.cache.ChannelContentCache
@ -42,24 +43,89 @@ abstract class SubscriptionsTaskFetchAlgorithm(
override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result { override fun getSubscriptions(subs: Map<Subscription, List<String>>): Result {
val tasks = getSubscriptionTasks(subs); 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" + Logger.i(TAG, "Starting Subscriptions Fetch:\n" +
" Tasks: ${tasks.filter { !it.fromCache }.size}\n" + " Tasks: ${taskCount}\n" +
" Cached: ${tasks.filter { it.fromCache }.size}"); " Cached: ${cacheCount}");
try { try {
//TODO: Remove this for(clientTasks in tasksGrouped) {
UIDialogs.toast("Tasks: ${tasks.filter { !it.fromCache }.size}\n" + val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size;
"Cached: ${tasks.filter { it.fromCache }.size}", false); val clientCacheCount = clientTasks.value.size - clientTaskCount;
if(clientCacheCount > 0) {
UIDialogs.toast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels.")
}
}
} catch (ex: Throwable){} } catch (ex: Throwable){}
val exs: ArrayList<Throwable> = arrayListOf(); 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>>(); val forkTasks = mutableListOf<ForkJoinTask<SubscriptionTaskResult>>();
var finished = 0; 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) { for(task in tasks) {
val forkTask = threadPool.submit<SubscriptionTaskResult> { val forkTask = threadPool.submit<SubscriptionTaskResult> {
@ -87,10 +153,6 @@ abstract class SubscriptionsTaskFetchAlgorithm(
pager = StatePlatform.instance.getChannelContent(task.client, pager = StatePlatform.instance.getChannelContent(task.client,
task.url, task.type, ResultCapabilities.ORDER_CHONOLOGICAL); task.url, task.type, ResultCapabilities.ORDER_CHONOLOGICAL);
pager = ChannelContentCache.cachePagerResults(scope, pager) {
onNewCacheHit.emit(task.sub, it);
};
val initialPage = pager.getResults(); val initialPage = pager.getResults();
task.sub.updateSubscriptionState(task.type, initialPage); task.sub.updateSubscriptionState(task.type, initialPage);
StateSubscriptions.instance.saveSubscription(task.sub); StateSubscriptions.instance.saveSubscription(task.sub);
@ -105,6 +167,27 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val channelEx = ChannelException(task.sub.channel, ex); val channelEx = ChannelException(task.sub.channel, ex);
finished++; finished++;
onProgress.emit(finished, forkTasks.size); 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) if (!withCacheFallback)
throw channelEx; throw channelEx;
else { else {
@ -117,39 +200,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} }
forkTasks.add(forkTask); forkTasks.add(forkTask);
} }
return forkTasks;
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);
} }
abstract fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask>; abstract fun getSubscriptionTasks(subs: Map<Subscription, List<String>>): List<SubscriptionTask>;
@ -160,7 +211,8 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val sub: Subscription, val sub: Subscription,
val url: String, val url: String,
val type: String, val type: String,
var fromCache: Boolean = false var fromCache: Boolean = false,
var urgency: Int = 0
); );
class SubscriptionTaskResult( class SubscriptionTaskResult(

View File

@ -100,6 +100,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val onSourceChanged = Event3<IVideoSource?, IAudioSource?, Boolean>(); val onSourceChanged = Event3<IVideoSource?, IAudioSource?, Boolean>();
val onSourceEnded = Event0(); val onSourceEnded = Event0();
val onChapterChanged = Event2<IChapter?, Boolean>();
val onVideoClicked = Event0(); val onVideoClicked = Event0();
val onTimeBarChanged = Event2<Long, Long>(); val onTimeBarChanged = Event2<Long, Long>();
@ -185,6 +187,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
override fun onScrubMove(timeBar: TimeBar, position: Long) { override fun onScrubMove(timeBar: TimeBar, position: Long) {
gestureControl.restartHideJob(); gestureControl.restartHideJob();
updateCurrentChapter(position);
} }
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
@ -233,17 +237,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
val delta = position - lastPos; val delta = position - lastPos;
if(delta > 1000 || delta < 0) { if(delta > 1000 || delta < 0) {
lastPos = position; lastPos = position;
val currentChapter = getCurrentChapter(position) updateCurrentChapter();
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 = "";
}
}
} }
} }
@ -256,6 +250,22 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
exoPlayer?.attach(_videoView, PLAYER_STATE_NAME); 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?) { fun setArtwork(drawable: Drawable?) {
if (drawable != null) { if (drawable != null) {
_videoView.defaultArtwork = drawable; _videoView.defaultArtwork = drawable;

View File

@ -3,10 +3,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="60dp" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingTop="6dp"
android:paddingBottom="7dp"
android:paddingStart="7dp" android:paddingStart="7dp"
android:paddingEnd="12dp" android:paddingEnd="12dp"
android:background="@drawable/background_big_button" android:background="@drawable/background_big_button"
@ -26,6 +24,8 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
android:id="@+id/button_text" android:id="@+id/button_text"
@ -47,7 +47,7 @@
android:textSize="12dp" android:textSize="12dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:fontFamily="@font/inter_extra_light" android:fontFamily="@font/inter_extra_light"
android:maxLines="1" android:maxLines="2"
android:ellipsize="end" android:ellipsize="end"
tools:text="Attempts to fetch your subscriptions from this source" /> tools:text="Attempts to fetch your subscriptions from this source" />
</LinearLayout> </LinearLayout>

View File

@ -150,6 +150,30 @@
android:textSize="12dp" android:textSize="12dp"
android:fontFamily="@font/inter_light" /> android:fontFamily="@font/inter_light" />
</LinearLayout> </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 <FrameLayout
android:id="@+id/contentContainer" android:id="@+id/contentContainer"

@ -1 +1 @@
Subproject commit 5011bfcddb084007b938e6276b11f63e940006eb Subproject commit ce7d9d0bc7cd18c4f50338e30a82ca132e24f70d